Compare 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
AidarKC 13693a0a53 Merge remote-tracking branch 'origin/main' into ui-channels-redesign
# Conflicts:
#	VERSION.properties
#	shine-UI/js/pages/registration-faq-view.js
#	shine-UI/js/services/shine-user-pda-service.js
2026-09-26 20:43:47 +03:00
AidarKC 451280edd2 Добавил больше цветовых схем 2026-09-26 20:32:37 +03:00
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:05:26 +03:00
123 changed files with 5645 additions and 3765 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.14.0
client.version=1.14.1
server.version=1.12.0
+41
View File
@@ -0,0 +1,41 @@
# Замечания к серверу и UI (найдено при редизайне)
Код по этим пунктам не менялся.
## Ошибки
1. **Лента «Подписки» пуста у логинов с заглавными буквами** (например `User_t2_03`).
`Net_ListSubscriptionsFeed_Handler.loadFollowedChannels` ищет `connections_state.login = ?` в исходном регистре,
а в `connections_state` логин хранится в нижнем регистре (FK на `solana_user_pda_current.normalized_login`).
Подписки в базе есть, но `followedChannels` пуст. Отсюда же «подписка не срабатывает» в UI:
кнопка не меняется на «Отписаться», канал не появляется в «Подписках».
2. **Превью в списке чатов — «Сообщение недоступно»**, хотя в самом чате сообщения расшифровываются.
`messages-list.js` строит превью по `dialog.lastMessageBlobB64`, `catch` глушит настоящую ошибку.
Проверить, не бывает ли так у реальных пользователей (например, если последнее сообщение отправлено с другого устройства).
3. **Прямой переход по адресу** `/settings`, `/profile`, `/channels/new` при загрузке страницы уводит на «Личные».
4. **`?localWsPort=` теряется при навигации**: после обновления страницы UI молча подключается к `shineup.me`.
## Безопасность / конфигурация
5. **Приватный ключ web-push в репозитории**: `SHiNE-server/src/main/resources/application.properties` → `webpush.vapid.private`.
Если он совпадает с продовым — перевыпустить и убрать из git.
6. По умолчанию сервер представляется логином прода `server.SHiNE.login=shineupme` — при запуске вне прода легко перепутать.
## На будущее
7. **Каждое действие перерисовывает страницу целиком** (лайк → шапка на мгновение «Канал / Загрузка…», лента перестраивается),
отсюда «дёрганье» при переходах назад. Нужны оптимистичные обновления (лайк/ответ меняют только свой элемент),
кэш загруженных лент между переходами, обновление без полной перерисовки.
8. **Кэш модулей после деплоя.** Страницы в `js/app.js` подключаются с метками `?v=…`, а `js/components/*` и `js/services/*` — без меток,
браузер может держать старую версию. Для изменённых страниц метки подняты до `?v=202609260900`;
для остальных модулей стоит проверить заголовки кэширования или ввести общую метку версии, как у CSS и `app.js`.
## Не сделано в редизайне
- Автопроверка серверов в настройках.
- Число подписчиков в шапке канала (нет в API).
+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.
+27 -13
View File
@@ -6,6 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
/>
<meta name="theme-color" content="#121419" />
<base href="/" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
@@ -24,6 +25,17 @@
document.documentElement.dataset.themeMode = mode;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
// Своя палитра (см. js/services/theme-service.js) применяется до отрисовки, без вспышки.
try {
const applied = JSON.parse(localStorage.getItem('shine-ui-palette-applied-v1') || 'null');
const colors = applied && applied[resolved];
if (colors) {
Object.keys(colors).forEach(function (role) {
if (/^#[0-9a-f]{6}$/i.test(colors[role])) document.documentElement.style.setProperty('--' + role, colors[role]);
});
if (colors.accent) document.documentElement.style.setProperty('--focus-ring', colors.accent);
}
} catch {}
}());
</script>
<script>
@@ -33,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/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/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';
@@ -196,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);
@@ -406,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 || '',
+22 -21
View File
@@ -52,37 +52,37 @@ import {
setContacts,
} from './state.js';
import * as startView from './pages/start-view.js?v=202606142105';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
import * as registerView from './pages/register-view.js?v=202606201650';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
import * as startView from './pages/start-view.js?v=202609260900';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202609260900';
import * as registerView from './pages/register-view.js?v=202609260900';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginView from './pages/login-view.js?v=202609260900';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js';
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
import * as profileView from './pages/profile-view.js?v=202607150910';
import * as profileView from './pages/profile-view.js?v=202609260900';
import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as walletView from './pages/wallet-view.js?v=202609260900';
import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as serverSettingsView from './pages/server-settings-view.js?v=202609260900';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as deviceView from './pages/device-view.js?v=202609260900';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
import * as clientPairingView from './pages/device-pairing-view.js?v=202609260900';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202609260900';
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js';
@@ -93,21 +93,21 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js?v=202608221218';
import * as messagesList from './pages/messages-list.js?v=202609260900';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608221218';
import * as chatView from './pages/chat-view.js?v=202609260900';
import * as userProfileView from './pages/user-profile-view.js';
import * as userProfileListView from './pages/user-profile-list-view.js';
import * as userRelationManageView from './pages/user-relation-manage-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelsList from './pages/channels-list.js?v=202609260900';
import * as channelView from './pages/channel-view.js';
import * as channelAboutView from './pages/channel-about-view.js';
import * as channelDonateView from './pages/channel-donate-view.js';
import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
import * as networkView from './pages/network-view.js?v=202608221226';
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
import * as networkView from './pages/network-view.js?v=202609260900';
import * as notificationsView from './pages/notifications-view.js?v=202609260900';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -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);
@@ -1417,7 +1417,8 @@ function renderApp() {
try {
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
currentChromeCleanup = () => chrome.dispose();
if (showAppChrome) {
// В переписке нижняя панель прячется (pageMeta.hideToolbar), чтобы не отнимать место у сообщений.
if (showAppChrome && !page.pageMeta?.hideToolbar) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
toolbarHeightObserver?.sync?.();
}
@@ -1449,7 +1450,7 @@ function refreshToolbarOnly() {
&& !(pageId === 'language-view' && !state.session.isAuthorized);
clearSlot(toolbarEl, '--toolbar-height');
if (showAppChrome) {
if (showAppChrome && !page.pageMeta?.hideToolbar) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
@@ -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,24 +431,27 @@ 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 freeHint = selectedFile
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
const freeBadge = priceInfo?.isFree
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
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
? '<span class="ar-attachment-ok">бесплатно через Turbo</span>'
: `нужен баланс Turbo: ${escapeHtml(uploadPrice)}`}`
: '';
mainMetaEl.innerHTML = `
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details">
<summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div>
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>Размер файла: ${escapeHtml(fileSize)}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details>
`;
previewMetaEl.innerHTML = '';
if (selectedPreviewEnabled && selectedPreviewFile) {
@@ -455,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>
`;
}
};
@@ -517,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>
`;
@@ -527,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>
`;
}
};
@@ -621,35 +625,47 @@ 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;
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head">
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
<div class="form-actions-grid">
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</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">Свой кошелёк</button>
</div>
${turboMode
? `
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
<p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
<div class="form-field">
<label for="turbo-key-source">Подписывать и пополнять через</label>
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
</div>
`
: `
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
<div class="form-field">
<label for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="select ar-attachment-wallet-select" id="ar-attach-wallet"></select>
<button class="text-btn ar-attachment-link" type="button" data-action="add-wallet">+ Добавить кошелёк</button>
</div>
${isAvatarMode ? '<p class="ar-attachment-note">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="ar-attachment-note">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
`}
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
${canShowHistory || canShowExisting ? `
<div class="ar-attachment-alt-sources">
${canShowHistory ? `<button class="text-btn ar-attachment-link" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Из журнала загрузок'}</button>` : ''}
${canShowExisting ? `<button class="text-btn ar-attachment-link" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Уже есть в Arweave (tx id)'}</button>` : ''}
</div>` : ''}
${fixedFile ? '' : `<label class="ar-attachment-file-picker">
<input class="ar-attachment-file-input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
<span class="ar-attachment-file-btn">Выбрать файл</span>
<span class="ar-attachment-file-name" data-file-name="true">Файл не выбран</span>
</label>`}
<div class="ar-attachment-meta" data-meta-main="true"></div>
<label class="meta-muted" data-preview-option="true" hidden>
<input type="checkbox" data-preview-toggle="true" />
@@ -658,10 +674,9 @@ export function openArweaveAttachmentManager({
<div class="ar-attachment-meta" data-meta-preview="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : '<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>'}
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
`;
@@ -734,7 +749,7 @@ export function openArweaveAttachmentManager({
}
}
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
root.querySelectorAll('[data-action="cancel"]').forEach((btn) => btn.addEventListener('click', () => close(resolve, null)));
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
if (turboMode && selectedFile && selectedSha256) {
@@ -745,6 +760,10 @@ export function openArweaveAttachmentManager({
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
}
fileEl?.addEventListener('change', () => {
const nameEl = root.querySelector('[data-file-name="true"]');
if (nameEl) nameEl.textContent = fileEl.files?.[0]?.name || 'Файл не выбран';
});
fileEl?.addEventListener('change', async () => {
selectedFile = fileEl.files?.[0] || null;
selectedSha256 = '';
@@ -1041,7 +1060,7 @@ export function openArweaveAttachmentManager({
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Ещё не прикреплён</span>' : ''}
</div>
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
+24 -6
View File
@@ -1,3 +1,4 @@
import { iconHtml } from './ui-icon.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
import { state } from '../state.js';
@@ -27,6 +28,7 @@ export function openChannelEditor({
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
destination = '',
key = 'reply',
extraControl = null,
initialText = '',
@@ -37,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');
@@ -76,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';
@@ -129,11 +135,18 @@ 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);
if (extraControl instanceof Node) body.append(extraControl);
body.append(textarea, attachmentsBox, error, clearButton);
const footer = document.createElement('footer');
@@ -141,7 +154,8 @@ export function openChannelEditor({
const attachButton = document.createElement('button');
attachButton.type = 'button';
attachButton.className = 'secondary-btn channel-editor__attach';
attachButton.textContent = 'Прикрепить';
attachButton.innerHTML = `${iconHtml('clip')}<span class="sr-only">Прикрепить</span>`;
attachButton.title = 'Прикрепить файл';
attachButton.hidden = !allowAttachments;
const counter = document.createElement('span');
counter.className = 'channel-editor__counter';
@@ -151,6 +165,10 @@ export function openChannelEditor({
submitButton.textContent = submitLabel;
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
footer.append(attachButton);
if (extraControl instanceof Node) {
footer.classList.add('has-extra');
footer.append(extraControl);
}
footer.append(counter, submitButton);
dialog.append(header, body, footer);
overlay.append(dialog);
+48
View File
@@ -0,0 +1,48 @@
// Возвращает Promise<boolean>: true — подтвердили, false — отмена, Escape или тап мимо.
export function confirmDialog({
title = 'Подтвердите действие',
text = '',
confirmLabel = 'Да',
cancelLabel = 'Отмена',
danger = false,
} = {}) {
return new Promise((resolve) => {
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = 'modal confirm-dialog';
modal.innerHTML = `
<div class="modal-card confirm-dialog__card" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<h2 class="modal-title" id="confirm-dialog-title"></h2>
<p class="confirm-dialog__text"></p>
<div class="form-actions-grid">
<button type="button" class="secondary-btn" data-answer="no"></button>
<button type="button" class="${danger ? 'destructive-btn confirm-dialog__danger' : 'primary-btn'}" data-answer="yes"></button>
</div>
</div>
`;
modal.querySelector('.modal-title').textContent = title;
const textEl = modal.querySelector('.confirm-dialog__text');
textEl.textContent = text;
textEl.hidden = !text;
modal.querySelector('[data-answer="no"]').textContent = cancelLabel;
modal.querySelector('[data-answer="yes"]').textContent = confirmLabel;
const finish = (answer) => {
document.removeEventListener('keydown', onKeydown, true);
modal.remove();
opener?.focus?.();
resolve(answer);
};
const onKeydown = (event) => {
if (event.key === 'Escape') { event.stopPropagation(); finish(false); }
};
modal.addEventListener('click', (event) => {
if (event.target === modal) { finish(false); return; }
const answer = event.target.closest('[data-answer]')?.dataset.answer;
if (answer) finish(answer === 'yes');
});
document.addEventListener('keydown', onKeydown, true);
document.body.append(modal);
modal.querySelector('[data-answer="no"]').focus();
});
}
+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 });
+153
View File
@@ -0,0 +1,153 @@
// Редактор точной настройки палитры.
import {
PALETTE_PRESETS,
PALETTE_ROLES,
exportPalette,
getPaletteSettings,
importPalette,
resolvePalette,
resolveThemeMode,
setPaletteSettings,
} from '../services/theme-service.js';
export function openPaletteEditor({ theme = resolveThemeMode(), onClose } = {}) {
let editTheme = theme === 'light' ? 'light' : 'dark';
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = 'modal palette-editor';
modal.innerHTML = `
<div class="modal-card palette-editor__card" role="dialog" aria-modal="true" aria-labelledby="palette-editor-title">
<div class="palette-editor__head">
<h2 class="modal-title" id="palette-editor-title">Цвета оформления</h2>
<button class="icon-btn palette-editor__close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="palette-editor__section">
<span class="palette-editor__label">Основа</span>
<select class="input palette-editor__presets" aria-label="Готовая цветовая тема"></select>
<div class="palette-editor__preview" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
</div>
<div class="palette-editor__section">
<span class="palette-editor__label">Настраиваемая тема</span>
<div class="tabs tabs--auto" role="radiogroup" aria-label="Тема для настройки">
<button type="button" class="tab-btn" role="radio" data-edit-theme="light">День</button>
<button type="button" class="tab-btn" role="radio" data-edit-theme="dark">Ночь</button>
</div>
<p class="palette-editor__hint">Правки видны сразу. Меняется только выбранная тема.</p>
</div>
<div class="palette-editor__roles"></div>
<details class="palette-editor__share">
<summary>Поделиться палитрой</summary>
<p class="palette-editor__hint">Скопируйте текст и отправьте команде или вставьте чужую палитру и нажмите «Применить».</p>
<textarea class="input palette-editor__json" rows="8" spellcheck="false"></textarea>
<p class="palette-editor__error" role="alert" hidden></p>
<div class="palette-editor__actions">
<button type="button" class="secondary-btn" data-action="copy">Скопировать</button>
<button type="button" class="secondary-btn" data-action="import">Применить</button>
</div>
</details>
<div class="palette-editor__actions">
<button type="button" class="secondary-btn" data-action="reset-theme">Сбросить эту тему</button>
<button type="button" class="primary-btn" data-action="done">Готово</button>
</div>
</div>
`;
const card = modal.querySelector('.palette-editor__card');
const presetsEl = modal.querySelector('.palette-editor__presets');
const rolesEl = modal.querySelector('.palette-editor__roles');
const jsonEl = modal.querySelector('.palette-editor__json');
const errorEl = modal.querySelector('.palette-editor__error');
for (const [id, preset] of Object.entries(PALETTE_PRESETS)) {
const option = document.createElement('option');
option.value = id;
option.textContent = preset.label;
presetsEl.append(option);
}
const update = (mutate) => {
const settings = getPaletteSettings();
const next = { preset: settings.preset, custom: { dark: { ...settings.custom.dark }, light: { ...settings.custom.light } } };
mutate(next);
setPaletteSettings(next);
render();
};
function render() {
const settings = getPaletteSettings();
const colors = resolvePalette(editTheme, settings);
const custom = settings.custom[editTheme];
presetsEl.value = settings.preset;
modal.querySelectorAll('.palette-editor__preview span').forEach((swatch, index) => {
swatch.style.backgroundColor = [colors.background, colors.surface, colors.accent, colors['surface-selected']][index];
});
modal.querySelectorAll('[data-edit-theme]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.editTheme === editTheme));
});
rolesEl.replaceChildren(...PALETTE_ROLES.map((role) => {
const row = document.createElement('label');
row.className = 'palette-editor__role';
const changed = Boolean(custom[role.id]);
row.innerHTML = `
<input type="color" value="${colors[role.id]}" aria-label="${role.label}">
<span class="palette-editor__role-name">${role.label}${changed ? ' <span class="palette-editor__changed">изменён</span>' : ''}</span>
<code class="palette-editor__role-value">${colors[role.id]}</code>
`;
const input = row.querySelector('input');
input.addEventListener('input', () => {
document.documentElement.style.setProperty(`--${role.id}`, input.value);
row.querySelector('code').textContent = input.value;
});
input.addEventListener('change', () => update((next) => { next.custom[editTheme][role.id] = input.value; }));
return row;
}));
jsonEl.value = exportPalette();
errorEl.hidden = true;
}
const close = () => {
document.removeEventListener('keydown', onKeydown);
modal.remove();
opener?.focus?.();
onClose?.();
};
const onKeydown = (event) => { if (event.key === 'Escape') close(); };
modal.addEventListener('click', async (event) => {
if (event.target === modal) { close(); return; }
// data-edit-theme, а не data-theme: data-theme стоит на <html>, и closest() находил бы его при любом клике.
const themeBtn = event.target.closest('[data-edit-theme]');
if (themeBtn) { editTheme = themeBtn.dataset.editTheme; render(); return; }
if (event.target.closest('.palette-editor__close')) { close(); return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'done') close();
if (action === 'reset-theme') update((next) => { next.custom[editTheme] = {}; });
if (action === 'copy') {
try {
await navigator.clipboard.writeText(jsonEl.value);
} catch {
jsonEl.select();
}
}
if (action === 'import') {
try {
importPalette(jsonEl.value);
render();
} catch {
errorEl.textContent = 'Не удалось прочитать палитру: проверьте, что текст скопирован целиком.';
errorEl.hidden = false;
}
}
});
presetsEl.addEventListener('change', () => update((next) => {
next.preset = presetsEl.value;
next.custom = { dark: {}, light: {} };
}));
document.addEventListener('keydown', onKeydown);
render();
document.body.append(modal);
card.querySelector('.palette-editor__close').focus();
return close;
}
+110
View File
@@ -0,0 +1,110 @@
// data-profile-list / data-self-profile-action / data-profile-action обрабатывают profile-view.js и user-profile-view.js.
import { iconHtml } from './ui-icon.js';
export function escapeProfileHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
const esc = escapeProfileHtml;
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)}». Число – сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
</button>`;
}
function statHtml({ kind, label, valueHtml, ariaLabel }) {
return `
<button type="button" class="pf-stat" data-profile-list="${esc(kind)}" aria-label="${esc(ariaLabel)}">
<span class="pf-stat-value">${valueHtml}</span>
<span class="pf-stat-label">${esc(label)}</span>
</button>`;
}
function contactRows(card) {
return [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
]
.filter(([, value]) => String(value || '').trim())
.map(([label, value]) => `
<div class="pf-row">
<span class="pf-row-label">${esc(label)}</span>
<span class="pf-row-value">${esc(value)}</span>
</div>`)
.join('');
}
/**
* @param {object} opts
* @param {object} opts.card карточка профиля (loadUserProfileCard)
* @param {string} opts.login логин на случай, если в карточке его нет
* @param {string} opts.tilesHtml плитки действий (свои/чужие)
* @param {string} [opts.tilesWrapClass] доп. класс обёртки плиток (для меню «Добавить»)
* @param {string} [opts.beforeTilesHtml] разметка внутри обёртки перед плитками
*/
export function profileCardHtml({ card, login = '', tilesHtml = '', tilesWrapClass = '', beforeTilesHtml = '', isSelf = true }) {
const stats = card?.stats || {};
const official = card?.accountRole === 'primary';
const shining = card?.shineStatus === 'shining';
const cardLogin = card?.login || login;
const fullName = [card?.firstName, card?.lastName].map((v) => String(v || '').trim()).filter(Boolean).join(' ');
const displayName = fullName || cardLogin || 'Профиль';
const about = String(card?.about || '').trim();
const spiritualPath = String(card?.spiritualPath || '').trim();
const contacts = contactRows(card);
const aboutRow = (about || isSelf) ? `
<div class="pf-row pf-row--block">
<span class="pf-row-label">О себе</span>
<p class="pf-row-text${about ? '' : ' is-empty'}">${esc(about || 'Не заполнено')}</p>
</div>` : '';
const pathRow = spiritualPath ? `
<div class="pf-row pf-row--block">
<span class="pf-row-label">Духовный путь</span>
<p class="pf-row-text">${esc(spiritualPath)}</p>
</div>` : '';
const listInner = aboutRow + contacts + pathRow;
const listHtml = listInner.trim() ? `<div class="pf-list">${listInner}</div>` : '';
const friends = Number(stats.friendsCount || 0);
const statusChips = [
(official || Number(stats.primaryReceivedCount || 0) > 0) && statusChip({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, active: official }),
(shining || Number(stats.shineReceivedCount || 0) > 0) && statusChip({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, active: shining }),
].filter(Boolean);
const statusesHtml = statusChips.length ? `<div class="pf-statuses">${statusChips.join('')}</div>` : '';
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<div class="pf-hero${shining ? ' is-shining' : ''}" aria-label="Профиль ${esc(cardLogin)}">
<div class="pf-avatar user-profile-avatar-slot"></div>
<h2 class="pf-name">${esc(displayName)}</h2>
<div class="pf-login">@${esc(cardLogin)}</div>
${statusesHtml}
</div>
<div class="pf-stats">
${statHtml({ kind: 'friends', label: 'Друзья', valueHtml: closeFriends ? `${friends}<small> · ${closeFriends} близк.</small>` : String(friends), ariaLabel: `Друзья: ${friends}, близкие: ${closeFriends}` })}
${statHtml({ kind: 'channels_owned', label: 'Каналы', valueHtml: String(Number(stats.ownedPublicChannelsCount || 0)), ariaLabel: `Каналы: ${Number(stats.ownedPublicChannelsCount || 0)}` })}
${statHtml({ kind: 'channels_following', label: 'Подписки', valueHtml: String(Number(stats.followingChannelsCount || 0)), ariaLabel: `Подписки: ${Number(stats.followingChannelsCount || 0)}` })}
</div>
${tilesHtml ? `
<div class="pf-tiles-wrap user-profile-actions-wrap ${esc(tilesWrapClass)}">
${beforeTilesHtml}
<div class="pf-tiles user-profile-actions">${tilesHtml}</div>
</div>` : ''}
${listHtml}`;
}
export function profileTileHtml({ icon, label, attrs = '', iconMarkup = '' }) {
return `<button type="button" class="pf-tile user-profile-action-btn" ${attrs}>${iconMarkup || iconHtml(icon)}<span>${esc(label)}</span></button>`;
}
@@ -1,3 +1,4 @@
import { iconHtml } from './ui-icon.js';
function resolveElement(value) {
return typeof value === 'function' ? value() : value;
}
@@ -6,7 +7,7 @@ function buildArrowIcon() {
const icon = document.createElement('span');
icon.className = 'scroll-to-bottom-btn__icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '↓';
icon.innerHTML = iconHtml('down');
return icon;
}
+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;
}
+28 -22
View File
@@ -1,18 +1,28 @@
import { resolveToolbarActive } from '../router.js';
import { state, authService } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { iconHtml as lineIcon } from './ui-icon.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
const TOOLBAR_ICONS = {
'messages-list': '<g transform="translate(12 12) scale(.88) translate(-11.5 -13)"><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"/><circle cx="8" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="11.7" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="15.4" cy="12" r="1.05" class="toolbar-svg-dot"/></g>',
'channels-list': '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
'notifications-view': '<path d="M6.2 16.8V11a5.8 5.8 0 0 1 11.6 0v5.8l1.7 1.7h-15Z"/><path d="M10.2 21a2 2 0 0 0 3.6 0"/>',
'profile-view': '<circle cx="12" cy="6.8" r="3.6"/><path d="M4.4 21v-2.1a5.6 5.6 0 0 1 5.6-5.6h4a5.6 5.6 0 0 1 5.6 5.6V21Z"/>',
};
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные' },
{ pageId: 'channels-list', label: 'Каналы' },
{ pageId: 'network-view', label: 'Связи' },
{ pageId: 'network-view', label: 'Связи', mandala: true },
{ pageId: 'notifications-view', label: 'Уведомления' },
{ pageId: 'profile-view', label: 'Профиль' },
];
function iconHtml(item) {
const names = { 'messages-list': 'message', 'channels-list': 'channels', 'network-view': 'network', 'notifications-view': 'bell', 'profile-view': 'profile' };
return lineIcon(names[item.pageId]);
function iconHtml(item, extra = '') {
const glyph = item.mandala
? `<img class="toolbar-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true" />`
: `<svg class="toolbar-svg" viewBox="0 0 24 24" aria-hidden="true">${TOOLBAR_ICONS[item.pageId]}</svg>`;
return `<span class="toolbar-icon${item.mandala ? ' toolbar-icon--mandala' : ''}">${glyph}${extra}</span>`;
}
function normalizeCounters(payload = {}) {
@@ -44,7 +54,7 @@ function renderBadge(btn, count, ariaLabel, extraClass = '') {
if (!badge) {
badge = document.createElement('span');
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
btn.append(badge);
(btn.querySelector('.toolbar-icon') || btn).append(badge);
}
badge.textContent = count > 99 ? '99+' : String(count);
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
@@ -86,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;
}
@@ -127,21 +137,17 @@ export function renderToolbar(currentPageId, navigate) {
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
if (isProfile) {
btn.innerHTML = `
${iconHtml(item)}
<span class="toolbar-label-wrap">
<span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
${iconHtml(item, `<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
<span class="toolbar-connection-dot" aria-hidden="true"></span>
</span>
</span>
</span>`)}
<span class="sr-only">${item.label}</span>
`;
} else if (isNetwork) {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
btn.setAttribute('aria-label', item.label);
btn.title = item.label;
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
} else {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
}
btn.title = item.label;
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
+7 -1
View File
@@ -1,3 +1,4 @@
import { iconHtml } from './ui-icon.js';
import { createDropdownMenu } from './dropdown-menu.js';
function appendNode(target, node) {
@@ -15,6 +16,11 @@ function createActionButton(action = {}, cleanupFns) {
if (action.iconNode instanceof Node) {
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 ?? '');
}
@@ -56,7 +62,7 @@ export function createTopBar({
const button = document.createElement('button');
button.type = 'button';
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
button.textContent = '←';
button.innerHTML = iconHtml('back');
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
button.title = backAction.title || 'Назад';
button.addEventListener('click', backAction.onClick);
+42 -5
View File
@@ -1,14 +1,51 @@
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"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
open: '<path d="M5 12h14M13 6l6 6-6 6"/>',
chevron: '<path d="m9 6 6 6-6 6"/>',
back: '<path d="M19 12H5M11 5l-7 7 7 7"/>',
down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
edit: '<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v3"/><rect x="4" y="8" width="16" height="11" rx="2.5"/><circle cx="16" cy="13.5" r="1.2" fill="currentColor" stroke="none"/>',
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>`;
}
+19 -25
View File
@@ -177,11 +177,11 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать root key только сейчас</span>
<span>Использовать главный ключ только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить root key на этом устройстве</span>
<span>Сохранить главный ключ на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
const passwordModal = createPasswordModal();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.className = 'settings-intro';
introCard.innerHTML = `
<p class="field-label">Где хранятся личные данные</p>
<p class="meta-muted">
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а настройки и приватная переписка хранятся только на выбранном сервере.
При смене сервера прежняя переписка и настройки автоматически не переносятся.
</p>
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
`;
const listCard = document.createElement('div');
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
listBody.className = 'stack';
const listStatus = document.createElement('p');
listStatus.className = 'meta-muted';
listStatus.textContent = 'Загрузка данных из PDA...';
listStatus.textContent = 'Загрузка данных аккаунта...';
listCard.append(listTitle, listHint, listBody, listStatus);
const addCard = document.createElement('div');
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
suggestEl.hidden = true;
const addStatus = document.createElement('p');
addStatus.className = 'meta-muted';
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
const addButton = document.createElement('button');
addButton.className = 'primary-btn';
addButton.type = 'button';
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else {
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
}
};
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
return;
}
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
try {
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
const logins = uniqueLogins(parsed?.accessServers);
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
renderServerList();
refreshAddButton();
listStatus.textContent = rows.length
? 'Сервер доступа загружен из PDA.'
: 'В PDA пользователя пока нет серверов доступа.';
? 'Сервер доступа загружен из записи аккаунта.'
: 'В записи аккаунта пока нет серверов доступа.';
} catch (error) {
currentAccessServers = [];
renderServerList();
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
</span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
@@ -500,10 +494,10 @@ export function render({navigate, chrome}) {
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
@@ -513,10 +507,10 @@ export function render({navigate, chrome}) {
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
}
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveRoot) {
@@ -616,15 +610,15 @@ export function render({navigate, chrome}) {
title: 'Сменить сервер доступа?',
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
note: resolved.httpBase
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
onConfirm: async () => {
const nextList = [resolved.serverLogin];
try {
await updateAccessServers(nextList, {
statusTarget: addStatus,
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
});
} catch {
// Сообщение уже показано в статусе.
+49 -23
View File
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) {
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24);
}
const TRANSLIT = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
};
function suggestChannelLogin(title) {
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
}
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
</div>
</div>
<label for="channel-name">Технический логин канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
<div class="meta-muted channel-link-preview" id="channel-link-preview">&nbsp;</div>
<div id="channel-name-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-title">Название канала</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
<div class="form-field__meta">
<div id="channel-title-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
</div>
</div>
<label for="channel-title">Как канал будет виден пользователям</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-name">Адрес канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
<div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
<div id="channel-name-error" class="inline-error"></div>
</div>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
<div id="channel-description-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
<textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
<div class="form-field__meta">
<div id="channel-description-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
</div>
</div>
<div id="channel-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.innerHTML = '&nbsp;';
linkPreviewEl.hidden = true;
linkPreviewEl.textContent = '';
return;
}
linkPreviewEl.hidden = false;
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
};
};
let nameEditedByHand = false;
nameEl.addEventListener('input', () => {
nameEditedByHand = nameEl.value.trim() !== '';
const raw = String(nameEl.value || '');
const sanitized = sanitizeChannelLoginInput(raw);
if (raw !== sanitized) {
nameEl.value = sanitized;
window.alert(CHANNEL_LOGIN_HINT);
}
if (raw !== sanitized) nameEl.value = sanitized;
updateValidation();
if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
});
titleEl.addEventListener('input', () => {
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
updateValidation();
});
titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => {
try {
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
const check = updateValidation();
if (check.name && /^[0-9]+$/.test(check.name)) {
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
nameEl.focus();
return;
}
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' };
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PERSONAL = 100;
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
screen.className = 'stack channels-screen channels-screen--add';
chrome?.setTopbar(createTopBar({
title: 'Новый персональный публичный чат',
title: 'Новый публичный чат',
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}));
+3 -4
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;
}
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
}));
const intro = document.createElement('div');
intro.className = 'card stack advanced-settings-intro';
intro.className = 'settings-intro';
intro.innerHTML = `
<p class="field-label">Скрытые возможности SHiNE</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по заголовку «Настройки».</p>
`;
const developerRow = createToggleRow({
+5 -9
View File
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
chrome?.setTopbar(createTopBar({
title: 'Лог приложения',
back: { label: '←', onClick: () => navigate('developer-settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
}));
const controls = document.createElement('div');
controls.className = 'card row';
controls.style.justifyContent = 'flex-start';
controls.style.gap = '8px';
controls.style.flexWrap = 'wrap';
controls.className = 'action-pair';
controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
<button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="destructive-btn" type="button" data-action="clear">Очистить</button>
`;
const status = document.createElement('div');
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Лог пока пуст.';
list.append(empty);
status.className = 'status-line is-available';
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
status.textContent = `Записей: ${entries.length}`;
}
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
const entries = getAppLogEntries();
if (!entries.length) {
+5 -5
View File
@@ -48,7 +48,7 @@ function renderTile(item, index) {
if (item.pendingPlacement) {
const flag = document.createElement('span');
flag.className = 'ar-attachment-placement-flag';
flag.textContent = 'Не добавлен в SHiNE';
flag.textContent = 'Ещё не прикреплён к записи';
meta.append(flag);
}
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
empty.className = 'empty-note';
empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
list.append(empty);
return;
}
@@ -115,11 +115,11 @@ export function render({navigate, chrome}) {
}
const topbar = createTopBar({
title: 'Загрузка файлов в блокчейн',
title: 'Файлы в блокчейне',
back: { onClick: () => navigate('settings-view') },
actions: [
{
label: '+',
icon: 'plus',
title: 'Добавить файл',
ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add',
+64 -48
View File
@@ -1,7 +1,8 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js';
import { renderAvatar } from '../components/avatar-image.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { navigateBack } from '../router.js';
import { navigateBack, getPreviousTrackedPath } from '../router.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
@@ -162,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>
@@ -251,7 +252,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
@@ -280,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;
@@ -294,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);
@@ -303,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();
});
@@ -410,12 +411,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
});
}
function subscribersWord(count) {
const n = Math.abs(Number(count) || 0) % 100;
const last = n % 10;
if (n > 10 && n < 20) return 'подписчиков';
if (last === 1) return 'подписчик';
if (last >= 2 && last <= 4) return 'подписчика';
return 'подписчиков';
}
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = createTopBar({
title: '',
title: 'О канале',
back: {
label: '←',
onClick: () => {
@@ -471,61 +481,66 @@ export function render({ navigate, route, chrome }) {
channelRootBlockHash: selector?.channelRootBlockHash,
});
const serverLink = buildChannelLink(shortRoute);
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
const subscriptionTile = isOwnChannel ? '' : `
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
</button>`;
content.innerHTML = `
<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-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
<div class="channel-about-subline">@${escapeHtml(channelName || ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
</div>
<section class="channel-about-section">
<h3>О канале</h3>
<div class="channel-about-tiles">
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${subscriptionTile}
${isOwnChannel ? '' : `<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Поддержать автора</span></button>`}
</div>
<div class="channel-about-list">
<div class="channel-about-row channel-about-row--static">
<span class="channel-about-row-label">Описание</span>
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
</section>
<section class="channel-about-section channel-about-owner-section">
<h3>Владелец канала</h3>
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
<strong>${escapeHtml(ownerName)}</strong>
<span>@${escapeHtml(ownerLogin)}</span>
</button>
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
</section>
<section class="channel-about-section">
<h3>Ссылка на этом сервере</h3>
<div class="channel-about-link-box">
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
</div>
</section>
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
${isOwnChannel ? '' : `
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
<button class="channel-about-row" id="channel-about-owner" type="button">
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
<span class="channel-about-row-main">
<strong>${escapeHtml(ownerName)}</strong>
<span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
</span>
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
</button>
`}
<button class="channel-about-row" id="channel-about-copy-row" type="button">
<span class="channel-about-row-main">
<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('copy')}</span>
</button>
</div>
`;
if (String(channel?.avaAr || '').trim()) {
const avatar = renderAvatar({
const channelAvatar = renderAvatar({
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
avatar: { ar: String(channel.avaAr || '').trim() },
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'xl',
className: 'channel-about-avatar channel-profile-avatar',
title: cleanName,
alt: 'Аватар канала',
});
avatar.style.setProperty('--channel-avatar-size', '112px');
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
} else {
content.querySelector('#channel-about-avatar-slot')?.remove();
}
content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
initials: ownerName.slice(0, 1).toUpperCase() || '?',
size: 'md',
className: 'channel-about-owner-avatar-img',
alt: '',
}));
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
const profileRoute = makeProfileRoute(ownerLogin);
@@ -537,7 +552,7 @@ export function render({ navigate, route, chrome }) {
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (shortRoute) navigate(shortRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
const copyLink = async () => {
if (!serverLink) return;
try {
await navigator.clipboard.writeText(serverLink);
@@ -545,7 +560,8 @@ export function render({ navigate, route, chrome }) {
} catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
}
});
};
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
const subscriptionButton = content.querySelector('#channel-about-subscription');
subscriptionButton?.addEventListener('click', async () => {
+8 -8
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>
@@ -238,7 +238,7 @@ export function render({ navigate, route, chrome }) {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
@@ -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();
});
+447 -88
View File
@@ -1,3 +1,4 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
@@ -8,6 +9,8 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
@@ -16,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,
@@ -25,7 +31,7 @@ import {
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -200,6 +206,13 @@ function allFeedSummaries() {
});
}
const channelTitleByLabel = new Map();
function rememberChannelTitle(label, channel) {
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
if (label && title) channelTitleByLabel.set(label, title);
}
function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
@@ -214,7 +227,9 @@ function resolveChannelDisplayName(channelSelector) {
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
));
if (!found) return '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
rememberChannelTitle(label, found.channel);
return label;
}
function resolveChannelHeadingFromNode(node) {
@@ -299,7 +314,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
if (!row?.channel?.channelName) return '';
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
rememberChannelTitle(label, row.channel);
return label;
} catch {
return '';
}
@@ -446,14 +463,14 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<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>
@@ -610,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);
@@ -702,7 +719,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
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);
headRow.append(authorTile);
@@ -786,7 +804,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
`;
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
@@ -817,7 +835,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
@@ -856,7 +874,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item';
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
actions.append(likeButton, discussionButton, shareButton, replyButton);
@@ -871,8 +889,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
if (!ownerLogin) return;
handlers.navigate(makeShineMessageRoute({
@@ -927,7 +943,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
@@ -944,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';
@@ -1008,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);
@@ -1025,22 +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.textContent = 'Тред в канале: ...';
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 = 'topbar-identity__title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'topbar-identity__sub';
threadHeaderChannel.textContent = '…';
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: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
actions: [{
label: '⋯',
ariaLabel: 'Меню обсуждения',
className: 'thread-menu-btn',
menu: { minWidth: 240, items: () => threadMenuActions?.() || [] },
}],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header);
@@ -1050,7 +1368,7 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
};
const showStatus = (message) => {
@@ -1175,10 +1493,10 @@ export function render({ navigate, route, chrome }) {
onShare: async (target) => {
try {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Тред',
text: 'Сообщение из треда SHiNE',
title: 'Сияние · Обсуждение',
text: 'Сообщение из обсуждения в Сиянии',
url: buildAbsoluteRouteUrl(routePath),
});
if (disposed) return;
@@ -1192,7 +1510,7 @@ export function render({ navigate, route, chrome }) {
onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) {
showStatus('Не удалось определить путь до треда.');
showStatus('Не удалось определить ссылку на обсуждение.');
return;
}
navigate(routePath);
@@ -1264,14 +1582,14 @@ export function render({ navigate, route, chrome }) {
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderChannel.textContent = '…';
threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null;
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
screen.append(invalid);
return;
}
@@ -1382,7 +1700,8 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
event.preventDefault();
@@ -1394,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 = 'primary-btn';
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
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 = 'card meta-muted';
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);
@@ -1478,7 +1837,7 @@ export function render({ navigate, route, chrome }) {
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
+124 -73
View File
@@ -1,5 +1,7 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { getPreviousTrackedPath } from '../router.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js';
import {
@@ -13,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
dayKey,
formatDayLabel,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -39,7 +44,7 @@ import {
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -639,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">
@@ -653,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>
@@ -828,14 +833,14 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<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>
@@ -874,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
button.addEventListener('click', async () => {
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
@@ -1063,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
if (list) {
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty);
} else {
@@ -1074,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', () => {
@@ -1166,23 +1171,25 @@ 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.textContent = 'Тип сообщения';
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
const typeSelect = document.createElement('select');
typeSelect.className = 'select';
typeSelect.className = 'select channel-editor__type-select';
typeSelect.title = 'Тип сообщения';
typeSelect.innerHTML = `
<option value="10">Публикация</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
`;
typeWrap.append(typeSelect);
return openChannelEditor({
id: 'channel-message-modal',
title: 'Новое сообщение',
title: 'Новая запись',
submitLabel: 'Опубликовать',
placeholder: 'Напишите сообщение',
placeholder: 'Чем хотите поделиться?',
destination: channelTitle ? `В канал «${channelTitle}»` : '',
key: `channel-post:${channelName}`,
extraControl: typeWrap,
isActive,
@@ -1217,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);
@@ -1269,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,
@@ -1501,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,
@@ -1611,14 +1621,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
};
}
function renderChannelMetaEventCard(event) {
function renderChannelMetaEventCard(event, dayLabel = '') {
const card = document.createElement('article');
card.className = 'card channel-system-event-card';
const label = event.kind === 'created'
? 'Создан канал'
? 'Канал создан'
: 'Изменено описание канала';
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
card.innerHTML = `
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
`;
card.addEventListener('click', () => {
openChannelMetaDetailsModal({
@@ -1855,7 +1866,8 @@ 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');
editedMarker.type = 'button';
@@ -1906,6 +1918,15 @@ function renderPostCard(post, {
navigate(makeProfileRoute(cleanLogin));
});
const isOwnerPost = !!selector
&& !isDiarySelector(selector)
&& !!post.messageRef?.blockchainName
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
if (isOwnerPost) {
card.classList.add('channel-message-card--owner');
authorTile.hidden = true;
}
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const parsedBody = parseMessageAttachments(post.body);
@@ -1990,7 +2011,7 @@ function renderPostCard(post, {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
<span class="channel-action-counter">${post.likesCount || ''}</span>
`;
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
@@ -2015,8 +2036,14 @@ function renderPostCard(post, {
discussionButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || 0}</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();
@@ -2030,7 +2057,7 @@ function renderPostCard(post, {
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
@@ -2050,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';
@@ -2067,7 +2092,9 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(shareButton, replyButton);
// Порядок как в макете 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';
@@ -2081,8 +2108,6 @@ function renderPostCard(post, {
event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
if (!ownerLogin) return;
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
navigate(makeShineMessageRoute({
ownerLogin,
messageBlockchainName: post.targetRef.blockchainName,
@@ -2111,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';
@@ -2133,15 +2158,31 @@ function renderPostCard(post, {
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
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: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await onEdit(post.messageRef, '', { isDelete: true }); }
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
}
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) {
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');
meta.append(timestamp);
if (moreButton) meta.append(moreButton);
actions.append(meta);
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
}
card.append(actions);
return card;
}
@@ -2162,12 +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 = 'primary-btn channel-main-action channel-main-action--compose';
addMessageButton.textContent = 'Добавить сообщение';
addMessageButton.className = 'channel-new-post';
addMessageButton.innerHTML = `${iconHtml('compose')}<span>Новая запись</span>`;
addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
handlers.onAddMessage();
@@ -2199,7 +2240,18 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
if (feedItems.length) {
let lastDayKey = '';
feedItems.forEach((item) => {
const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey;
// В ленте канала дата стоит у каждой записи; плашки дней остаются только в дневнике
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>`;
feed.append(separator);
}
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
@@ -2208,7 +2260,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
return;
}
const row = renderPostCard(item.post, {
@@ -2232,7 +2284,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
@@ -2258,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);
}
@@ -2370,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: '⋮',
@@ -2400,36 +2457,20 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = [];
items.push({ label: 'О канале', iconHtml: iconHtml('info'), action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({
label: 'Добавить сообщение',
action: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
items.push({ label: 'Новая запись', iconHtml: iconHtml('compose'), action: () => activeAddMessage?.() });
}
},
});
},
});
}
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
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) {
@@ -2479,9 +2520,6 @@ export function render({ navigate, route, chrome }) {
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({
login,
@@ -2694,7 +2732,7 @@ export function render({ navigate, route, chrome }) {
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Каналы',
text: 'Тред из канала SHiNE',
text: 'Обсуждение в Сиянии',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (disposed) return;
@@ -2806,7 +2844,13 @@ export function render({ navigate, route, chrome }) {
if (disposed) return;
const seq = ++refreshSeq;
const hadContent = !!screen.querySelector('.channel-feed');
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
const previousPath = getPreviousTrackedPath() || '';
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
const restorePosition = hadContent
? getChannelScrollRoot()?.scrollTop
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
if (!hadContent) clearContent();
activeChannelData = null;
activeOpenEntrypointHistory = null;
@@ -2843,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);
@@ -2885,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 }) => {
+9 -6
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
formatListTime,
readChannelNotificationsState,
showToast,
softHaptic,
@@ -755,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(lastMessage.createdAtMs || 0),
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
isOwnChannel: isOwn,
isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true,
@@ -933,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));
@@ -964,12 +965,13 @@ function renderChannelMain(channel) {
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
titleLine.append(title, time);
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = `@${channel.ownerName || 'автор'}`;
technical.hidden = !!channel.isOwnChannel;
if (channel.channelDescription) {
const desc = document.createElement('p');
@@ -984,6 +986,7 @@ function renderChannelMain(channel) {
const preview = document.createElement('p');
preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
if (!channel.messagePreview) preview.classList.add('is-empty');
previewLine.append(preview);
@@ -1160,7 +1163,7 @@ export function render({ navigate, route, chrome }) {
className: 'topbar--root',
actions: [
{
label: '+',
icon: 'plus',
title: 'Создать канал',
ariaLabel: 'Создать канал',
className: 'channels-create-btn',
@@ -1173,7 +1176,7 @@ export function render({ navigate, route, chrome }) {
controls.className = 'channels-list-controls';
const searchWrap = document.createElement('div');
searchWrap.className = 'channels-inline-search';
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.value = listState.query;
+42 -10
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';
@@ -28,7 +29,7 @@ import {
stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
import {
@@ -44,6 +45,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
export const pageMeta = {
id: 'chat-view',
title: 'Чат',
hideToolbar: true,
shellMode: {
topFade: true,
bottomFade: true,
@@ -81,7 +83,7 @@ function chatRelationLabel(value) {
case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг';
case 'contact': return 'Контакт';
default: return 'Не в контактах';
default: return '';
}
}
@@ -97,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);
@@ -117,7 +119,11 @@ function createChatHeaderParts(login, navigate) {
const lastName = String(currentPeer?.lastName || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
const relation = chatRelationLabel(currentPeer?.relationType);
// Статус аккаунта, а не «в сети»: присутствие сервер не сообщает, выдумывать его нельзя
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,
firstName,
@@ -765,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();
@@ -1058,7 +1072,19 @@ function renderLog(
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
let lastDayKey = '';
messages.forEach((msg) => {
const msgTimeMs = resolveMessageTimeMs(msg);
const msgDayKey = dayKey(msgTimeMs);
if (msgDayKey && msgDayKey !== lastDayKey) {
lastDayKey = msgDayKey;
const daySep = document.createElement('div');
daySep.className = 'dm-day-separator';
const dayLabel = document.createElement('span');
dayLabel.textContent = formatDayLabel(msgTimeMs);
daySep.append(dayLabel);
list.append(daySep);
}
const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
@@ -1176,14 +1202,19 @@ function renderLog(
const timeNode = document.createElement('span');
timeNode.className = 'bubble-time';
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
const status = resolveDeliveryStatus(messages, msg);
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);
}
@@ -1413,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: [
{
@@ -1812,7 +1844,7 @@ export function render({ navigate, route, chrome }) {
updateSendButtonMode();
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
showToast(
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
{ kind: 'error', timeoutMs: 2600 },
);
}
+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>
+19 -14
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
let searchSeq = 0;
const input = document.createElement('input');
input.className = 'input dm-input contact-search-input';
input.className = 'search-field__input contact-search-input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите начало логина';
input.placeholder = 'Логин или его начало';
input.setAttribute('aria-label', 'Логин для поиска');
input.enterKeyHint = 'search';
input.autocomplete = 'off';
input.maxLength = 80;
const resultsCard = document.createElement('section');
resultsCard.className = 'card stack contact-search-results-card';
resultsCard.className = 'stack contact-search-results';
resultsCard.hidden = true;
const status = document.createElement('p');
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
searchTimer = window.setTimeout(() => {
searchTimer = 0;
void runSearch();
}, 2000);
}, 500);
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn';
searchButton.className = 'search-field__action';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.textContent = 'Найти';
searchButton.addEventListener('click', async () => {
if (searchTimer) {
window.clearTimeout(searchTimer);
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
input.addEventListener('input', () => {
scheduleSearch();
});
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
searchButton.click();
});
const controls = document.createElement('div');
controls.className = 'contact-search-actions';
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack contact-search-form-card';
formCard.append(input, controls);
const formCard = document.createElement('label');
formCard.className = 'search-field contact-search-field';
formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
formCard.append(input, searchButton);
resultsCard.append(status, resultsList);
chrome?.setTopbar(createTopBar({
title: 'Поиск контактов',
title: 'Найти человека',
back: { label: '←', onClick: () => navigate('messages-list') },
}));
screen.append(
+22 -16
View File
@@ -207,11 +207,11 @@ async function forceUiUpdateNow() {
function showClientUpdateHelp() {
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '3) Если приложение установлено как PWA – удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
+ '5) Если всё ещё старая версия – откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
}
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
}));
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
card.className = 'nav-list settings-developer-card';
card.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
<button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
<button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
<button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
<button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
<button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
<button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
<button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
<button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
<button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
`;
const appLogBtn = card.querySelector('#settings-app-log');
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
}
});
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
const setPwaLabel = (text) => {
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
if (labelEl) labelEl.textContent = text;
else pwaInstallBtn.textContent = text;
};
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
setPwaLabel('Приложение установлено – проверить уведомления');
return;
}
if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
setPwaLabel('Установить как приложение');
return;
}
pwaInstallBtn.textContent = 'Как установить PWA';
setPwaLabel('Установка на главный экран');
};
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
+6 -16
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'),
};
}
@@ -250,7 +240,7 @@ export function render({navigate, chrome}) {
passwordDialog.style.inset = '0';
passwordDialog.style.zIndex = '30';
passwordDialog.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
<div class="stack" style="gap:6px;">
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
@@ -292,7 +282,7 @@ export function render({navigate, chrome}) {
dialogOverlay.style.inset = '0';
dialogOverlay.style.zIndex = '2';
dialogOverlay.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
<div style="position:absolute; inset:0; background:var(--scrim);"></div>
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
+79 -74
View File
@@ -1,3 +1,4 @@
import { showToast } from '../services/channels-ux.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
@@ -11,24 +12,49 @@ import {
export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Homeserver';
if (Number(sessionType) === 50) return 'Wallet';
if (Number(sessionType) === 1) return 'Client';
return `Type ${Number(sessionType) || 0}`;
if (Number(sessionType) === 100) return 'Домашний сервер';
if (Number(sessionType) === 50) return 'Кошелёк';
if (Number(sessionType) === 1) return 'Приложение';
return `Тип ${Number(sessionType) || 0}`;
}
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
function describeClient(userAgent) {
const ua = String(userAgent || '');
if (!ua) return 'Неизвестное устройство';
const browser = /Edg\//.test(ua) ? 'Edge'
: /OPR\/|Opera/.test(ua) ? 'Opera'
: /Firefox\//.test(ua) ? 'Firefox'
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: '';
const os = /iPhone/.test(ua) ? 'iPhone'
: /iPad/.test(ua) ? 'iPad'
: /Android/.test(ua) ? 'Android'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
: /Linux|X11/.test(ua) ? 'Linux'
: '';
const parts = [browser, os].filter(Boolean);
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
}
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const date = new Date(ms);
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
if (days === 0) return `сегодня, ${time}`;
if (days === 1) return `вчера, ${time}`;
const sameYear = date.getFullYear() === new Date().getFullYear();
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
return `${day}, ${time}`;
}
function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'Online now' : 'Offline';
return onlineOnThisServer ? 'в сети' : 'не в сети';
}
function sortSessionsByOnline(sessions = []) {
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
chrome?.setTopbar(createTopBar({
title: 'Устройства',
back: { label: '←', onClick: () => navigate('settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
}));
const actions = document.createElement('div');
actions.className = 'card stack';
actions.className = 'nav-list';
actions.innerHTML = `
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
`;
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div');
sessionsBlock.className = 'card stack';
sessionsBlock.className = 'stack device-sessions';
const buildList = () => {
sessionsBlock.innerHTML = '';
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
const item = document.createElement('button');
item.className = 'ui-button session-item';
item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType);
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
item.innerHTML = `
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px; text-align:left;">
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
<span class="meta-muted">${session.geo || 'unknown'}</span>
</div>
<span class="session-item__main">
<strong class="session-item__title"></strong>
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
</span>
<span class="session-item__side">
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
</div>
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
</span>
`;
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
item.title = String(session.clientInfoFromClient || '');
const geoEl = item.querySelector('.session-item__geo');
if (geoEl) geoEl.textContent = session.geo;
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
return item;
};
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
return;
}
const currentMenu = document.createElement('div');
currentMenu.className = 'stack';
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
currentMenu.append(createSessionItem(current, true));
const endCurrentSessionBtn = document.createElement('button');
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
endCurrentSessionBtn.type = 'button';
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
endCurrentSessionBtn.addEventListener('click', async () => {
const confirmed = window.confirm('Хотите завершить текущую сессию?');
if (!confirmed) return;
try {
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
closeServerSession: true,
});
} catch (error) {
if (!isSessionInvalidError(error)) {
setAuthError(error.message);
window.alert(error.message);
return;
}
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
});
}
});
currentMenu.append(endCurrentSessionBtn);
const othersMenu = document.createElement('div');
othersMenu.className = 'stack';
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
const groupTitle = (text) => {
const title = document.createElement('p');
title.className = 'nav-list__title';
title.textContent = text;
return title;
};
const currentList = document.createElement('div');
currentList.className = 'nav-list';
currentList.append(createSessionItem(current, true));
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
if (others.length === 0) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Других сеансов нет.';
othersMenu.append(empty);
sessionsBlock.append(empty);
} else {
others.forEach((session) => {
othersMenu.append(createSessionItem(session, false));
});
const othersList = document.createElement('div');
othersList.className = 'nav-list';
others.forEach((session) => othersList.append(createSessionItem(session, false)));
sessionsBlock.append(othersList);
}
sessionsBlock.append(currentMenu, othersMenu);
};
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
async function reloadSessions() {
try {
await refreshSessions();
buildList();
setAuthInfo('Список сессий обновлён.');
setAuthInfo('Список сеансов обновлён.');
showToast('Список сеансов обновлён');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
return;
}
setAuthError(error.message);
window.alert(error.message);
showToast(error.message, { kind: 'error' });
}
}
});
buildList();
screen.append(actions, sessionsBlock);
+4 -4
View File
@@ -58,7 +58,7 @@ export function render() {
amountHint.style.fontSize = '22px';
amountHint.style.fontWeight = '800';
amountHint.style.lineHeight = '1.25';
amountHint.style.color = '#1fc97a';
amountHint.style.color = 'var(--success)';
amountHint.style.textAlign = 'center';
amountHint.textContent = '';
@@ -69,7 +69,7 @@ export function render() {
balanceHint.style.margin = '0';
balanceHint.style.textAlign = 'center';
balanceHint.style.fontSize = '14px';
balanceHint.style.color = 'rgba(255,255,255,0.9)';
balanceHint.style.color = 'var(--text-primary)';
balanceHint.textContent = '';
const closeHint = document.createElement('p');
@@ -79,7 +79,7 @@ export function render() {
closeHint.style.fontSize = '26px';
closeHint.style.fontWeight = '800';
closeHint.style.lineHeight = '1.25';
closeHint.style.color = '#1fc97a';
closeHint.style.color = 'var(--success)';
closeHint.style.textAlign = 'center';
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
@@ -140,7 +140,7 @@ export function render() {
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
status.style.fontSize = '13px';
status.style.fontWeight = '500';
status.style.color = 'rgba(255,255,255,0.88)';
status.style.color = 'var(--text-secondary)';
fillBtn.style.display = 'none';
actions.style.display = 'none';
amountHint.style.display = '';
+67 -48
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: 'shineServerLogin', label: 'Логин сервера Сияние' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
{ key: 'solanaServer', label: 'Сервер Solana' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ 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 = 'shine-btn shine-btn--settings 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 = 'shine-btn shine-btn--settings preauth-local-demo-btn';
localDemoButton.className = 'secondary-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
@@ -143,22 +163,16 @@ export function render({ navigate }) {
}
const serverUiButton = document.createElement('button');
serverUiButton.className = 'shine-btn shine-btn--settings';
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 = 'shine-btn shine-btn--settings';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button');
saveButton.className = 'shine-btn shine-btn--primary';
saveButton.className = 'primary-btn';
saveButton.type = 'button';
saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', async () => {
@@ -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;
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function render({ navigate }) {
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
+20 -37
View File
@@ -14,55 +14,38 @@ export function render({navigate, chrome}) {
const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
chrome?.setTopbar(createTopBar({
const topbar = createTopBar({
title: 'Язык / Language',
back: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
} },
}));
});
const embedTopbar = !state.session.isAuthorized;
if (!embedTopbar) chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack language-choice-card';
card.innerHTML = `
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
<span class="language-segmented-thumb" aria-hidden="true"></span>
</div>
<div class="language-choice-actions">
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button>
</div>
`;
const syncSelection = () => {
card.dataset.language = pendingLanguage;
card.querySelectorAll('[data-language]').forEach((item) => {
const selected = item.dataset.language === pendingLanguage;
item.classList.toggle('is-selected', selected);
item.setAttribute('aria-checked', String(selected));
});
};
syncSelection();
card.querySelectorAll('[data-language]').forEach((button) => {
button.addEventListener('click', () => {
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
syncSelection();
});
});
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
card.className = 'nav-list language-list';
card.setAttribute('role', 'radiogroup');
card.setAttribute('aria-label', 'Язык интерфейса / Interface language');
[['ru', 'Русский'], ['en', 'English']].forEach(([code, label]) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'nav-row language-row';
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', String(pendingLanguage === code));
row.innerHTML = `<span class="nav-row__label"></span>`;
row.querySelector('.nav-row__label').textContent = label;
row.addEventListener('click', () => {
pendingLanguage = code;
saveEntryLanguage(code);
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
});
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
saveEntryLanguage(pendingLanguage);
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
card.append(row);
});
if (embedTopbar) screen.append(topbar);
screen.append(card);
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);
+4 -4
View File
@@ -66,7 +66,7 @@ export function render({ navigate }) {
const remoteText = document.createElement('p');
remoteText.className = 'auth-copy';
const serverLoginEl = document.createElement('strong');
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
const textBefore = document.createTextNode('Этот пользователь Сияния зарегистрирован на другом сервере доступа: ');
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
const serverLinkEl = document.createElement('a');
@@ -111,11 +111,11 @@ export function render({ navigate }) {
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
if (resolution === 'NOT_FOUND') {
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
setStatus(status, 'Пользователь с таким логином не зарегистрирован в Сиянии.');
return;
}
if (resolution === 'NO_ACCESS_SERVER') {
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
setStatus(status, 'Пользователь зарегистрирован в Сиянии, но для него не найден действующий сервер доступа.');
return;
}
if (resolution === 'REMOTE') {
@@ -155,7 +155,7 @@ export function render({ navigate }) {
screen.append(
createTopBar({
title: '',
title: 'Вход',
back: {
label: '←',
onClick: async () => {
+58 -79
View File
@@ -8,21 +8,13 @@ import {
terminateCurrentSession,
} from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { formatRelativeTime } from '../services/channels-ux.js';
import { formatListTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const SVG_CHEVRON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
`;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
return `${normalized.slice(0, maxLen - 1)}…`;
}
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
async function resolveDialogPreview(dialog) {
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
if (localText) return localText;
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
// чтобы превью появилось, когда пароль снова будет введён.
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = 'Сообщение недоступно';
const fallback = DM_PREVIEW_ENCRYPTED;
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
}
function formatChatRowTime(ts) {
return formatRelativeTime(ts);
return formatListTime(ts);
}
function compareChatRows(a, b) {
@@ -201,63 +198,55 @@ export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const brand = document.createElement('div');
brand.className = 'dm-head-brand';
const logoWrap = document.createElement('span');
logoWrap.className = 'dm-head-logo-wrap';
logoWrap.setAttribute('aria-hidden', 'true');
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
brand.append(logoWrap);
let currentChatFilter = 'all';
const filterTitle = document.createElement('button');
filterTitle.type = 'button';
filterTitle.className = 'dm-head-filter-title';
filterTitle.textContent = 'Чаты';
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
friend: 'Друзья',
contact: 'Контакты',
none: 'Новые',
};
const CHAT_FILTERS = [
['all', 'Все'],
['close_friend', 'Близкие'],
['friend', 'Друзья'],
['contact', 'Контакты'],
['none', 'Новые'],
];
let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: filterTitle,
placement: 'bottom-start',
leftShift: 72,
minWidth: 225,
items: [
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
],
const filterRow = document.createElement('div');
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 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 searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const head = createTopBar({
left: brand,
center: filterTitle,
title: 'Чаты',
className: 'topbar--root',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню чатов',
ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
menu: {
minWidth: 210,
items: [
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
],
},
icon: 'search',
title: 'Найти человека',
ariaLabel: 'Найти человека',
className: 'messages-search-btn',
onClick: () => navigate('contact-search-view'),
},
],
});
@@ -269,9 +258,7 @@ function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar,
firstName: item.firstName,
@@ -280,19 +267,19 @@ function renderRow(item) {
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
avatarWrap.append(avatarEl);
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong>
<span class="dm-contact-note">${relationBadge}</span>
${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
</div>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
<div class="dm-row-meta-line">
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
</div>
@@ -310,6 +297,7 @@ function renderRow(item) {
void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row;
@@ -387,20 +375,13 @@ function renderRow(item) {
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет диалогов';
empty.className = 'empty-note';
empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
list.append(empty);
return;
}
let dividerInserted = false;
rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item));
});
} catch (error) {
@@ -444,12 +425,10 @@ function renderRow(item) {
reloadForFilter = () => { void loadList(); };
chrome?.setTopbar(head);
screen.append(list);
screen.append(filterRow, list);
loadList();
screen.cleanup = () => {
chatFilterMenu.destroy();
};
screen.cleanup = () => {};
return screen;
}
+13 -24
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
@@ -358,7 +357,7 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) {
.filter(Boolean);
if (failedParents.length) {
const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', ');
throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
throw new Error(`Друзья друзей: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
}
// ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра.
@@ -930,16 +929,16 @@ export function render({ navigate, route, chrome } = {}) {
function updateHistoryChip() {
if (!(historyChip instanceof HTMLButtonElement)) return;
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
historyChip.textContent = historyDepth > 0 ? `Недавние · ${historyDepth}` : 'Недавние';
historyChip.classList.toggle('is-active', historyDepth > 0);
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
historyChip.disabled = x2Enabled;
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
historyChip.title = x2Enabled
? 'X2 показывает отдельную карту и временно не использует историю.'
? '«Друзья друзей» показывают отдельную карту – недавние временно не используются.'
: (historyDepth > 0
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.');
? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
: 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
}
function cycleHistoryDepth() {
@@ -956,7 +955,7 @@ export function render({ navigate, route, chrome } = {}) {
if (!(x2Chip instanceof HTMLButtonElement)) return;
x2Chip.classList.toggle('is-active', x2Enabled);
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы скрыть.' : 'Показать друзей друзей.';
updateHistoryChip();
}
@@ -1289,26 +1288,16 @@ export function render({ navigate, route, chrome } = {}) {
}
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const header = createTopBar({
title: 'Связи',
className: 'topbar--root',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
icon: 'search',
title: 'Найти пользователя',
ariaLabel: 'Найти пользователя',
className: 'network-search-btn',
onClick: openSearchModal,
},
],
});
@@ -1365,7 +1354,7 @@ export function render({ navigate, route, chrome } = {}) {
x2Chip = document.createElement('button');
x2Chip.type = 'button';
x2Chip.className = 'fg-filter-chip fg-x2-chip';
x2Chip.textContent = 'X2';
x2Chip.textContent = 'Друзья друзей';
x2Chip.addEventListener('click', () => { void toggleX2(); });
filterBar.append(x2Chip);
updateX2Chip();
+42 -21
View File
@@ -106,8 +106,27 @@ function easeOutCubic(t) {
return 1 - x * x * x;
}
const RELATION_COLORS_LIGHT = {
family: 'rgba(184, 92, 20, 0.9)',
friend: 'rgba(38, 96, 196, 0.9)',
close_friend: 'rgba(38, 96, 196, 0.98)',
business: 'rgba(108, 62, 200, 0.9)',
contact: 'rgba(92, 98, 121, 0.75)',
};
function isLightTheme() {
return document.documentElement.dataset.theme === 'light';
}
function shineStroke() {
return isLightTheme()
? { halo: '#0a7fb3', core: '#06567a', blend: 'normal' }
: { halo: '#00e5ff', core: '#dffaff', blend: 'screen' };
}
function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
const palette = isLightTheme() ? RELATION_COLORS_LIGHT : RELATION_COLORS;
return palette[relationType] || palette.contact;
}
function resolveAvatarPhotoSrc(src) {
@@ -236,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-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
@@ -637,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])); }
@@ -677,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(' ');
@@ -692,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 };
@@ -764,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;
@@ -801,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;
@@ -815,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); // лёгкий «поп» при выплывании
@@ -842,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;
@@ -856,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;
@@ -967,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;
@@ -988,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;
@@ -1057,8 +1076,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
}
@@ -1067,8 +1087,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
}
+77 -104
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,82 +138,60 @@ 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');
text.className = 'meta-muted';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или обсуждении, ответ появится здесь.';
card.append(title, text);
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 = '·';
head.append(name);
const time = document.createElement('span');
time.className = 'notification-time';
time.textContent = relative;
primary.append(separator, time);
}
text.append(primary);
header.append(text);
return header;
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;
}
@@ -322,15 +285,18 @@ export function render({ navigate, chrome } = {}) {
screen.className = 'stack notifications-screen';
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
tabs.className = 'tabs tabs--three notification-feed-tabs';
tabs.setAttribute('role', 'radiogroup');
tabs.setAttribute('aria-label', 'Категория уведомлений');
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
chrome?.setTopbar(createTopBar({
center: tabs,
className: 'notifications-topbar',
title: 'Уведомления',
className: 'topbar--root notifications-topbar',
}));
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;
@@ -352,15 +318,22 @@ export function render({ navigate, chrome } = {}) {
if (!btn) return;
let badge = btn.querySelector('.notification-toolbar-badge');
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; (btn.querySelector('.toolbar-icon') || btn).append(badge); }
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
}
function renderTabs(payload) {
const counts = countsFromPayload(payload);
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
const b=document.createElement('button'); b.type='button'; b.className='tab-btn notification-tab-btn'; b.dataset.tab=key; b.setAttribute('role','radio'); b.setAttribute('aria-checked',state.notificationsTab===key?'true':'false');
b.textContent = label;
if (counts[key] > 0) {
const count = document.createElement('span');
count.className = 'tab-count';
count.textContent = counts[key] > 99 ? '99+' : String(counts[key]);
count.setAttribute('aria-label', `новых: ${counts[key]}`);
b.append(count);
}
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
return b;
}));
@@ -397,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'}));
}
}
+20 -23
View File
@@ -114,7 +114,7 @@ export function render({ navigate, chrome }) {
);
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'stack profile-edit-card';
const topRow = document.createElement('div');
topRow.className = 'row';
@@ -127,14 +127,14 @@ export function render({ navigate, chrome }) {
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
<button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
`;
const badgesRow = document.createElement('div');
badgesRow.className = 'row';
badgesRow.className = 'chip-row profile-edit-statuses';
badgesRow.innerHTML = `
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
<button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
<button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
`;
const status = document.createElement('div');
@@ -142,16 +142,13 @@ export function render({ navigate, chrome }) {
status.textContent = 'Загрузка параметров...';
const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list';
listWrap.className = 'nav-list profile-param-list';
const relativesCard = document.createElement('div');
relativesCard.className = 'card stack';
relativesCard.innerHTML = `
<div class="profile-param-value"><b>Друзья</b></div>
<div class="meta-muted">
Добавьте пользователя в друзья или в близкие друзья.
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
</div>
<div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
`;
@@ -250,7 +247,7 @@ export function render({ navigate, chrome }) {
function updateStatusesUi() {
if (accountRoleBtn) {
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
}
@@ -582,22 +579,22 @@ export function render({ navigate, chrome }) {
function renderFields(fields) {
listWrap.innerHTML = '';
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
const row = document.createElement('button');
row.type = 'button';
row.className = 'nav-row profile-param-item';
row.dataset.editField = field.key;
row.style.cursor = 'pointer';
const value = String(field.value || '').trim() || 'не заполнено';
const isNameField = field.key === 'first_name' || field.key === 'last_name';
const valueClass = isNameField ? 'profile-param-value profile-param-value-small' : 'profile-param-value';
row.innerHTML = `<div class="${valueClass}"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
const value = String(field.value || '').trim();
row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
listWrap.append(row);
if (field.key === 'last_name') {
const genderRow = document.createElement('div');
genderRow.className = 'card profile-param-item row';
const genderRow = document.createElement('button');
genderRow.type = 'button';
genderRow.className = 'nav-row profile-param-item';
genderRow.dataset.editGender = 'true';
genderRow.style.cursor = 'pointer';
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: <span data-gender-value>${escapeHtml(genderLabel(currentGender))}</span></div>`;
const genderText = genderLabel(currentGender);
const genderEmpty = genderText === 'Не указан';
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
listWrap.append(genderRow);
}
});
@@ -712,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;
}
+17 -158
View File
@@ -1,104 +1,20 @@
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const topbar = createTopBar({
title: login || 'Профиль',
className: 'topbar--profile user-profile-header',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn topbar-overflow-action--raised',
menu: {
className: 'profile-head-menu',
minWidth: 250,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
},
},
],
title: 'Профиль',
className: 'topbar--root user-profile-header',
});
chrome?.setTopbar(topbar);
@@ -114,57 +30,21 @@ export function render({ navigate, chrome }) {
function renderProfile() {
if (!card) return;
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = `
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
</div>
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
<div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
</div>
body.innerHTML = profileCardHtml({
card,
login,
tilesHtml: [
profileTileHtml({ icon: 'edit', label: 'Редактировать', attrs: 'data-self-profile-action="edit"' }),
profileTileHtml({ icon: 'wallet', label: 'Кошелёк', attrs: 'data-self-profile-action="wallet"' }),
profileTileHtml({ icon: 'settings', label: 'Настройки', attrs: 'data-self-profile-action="settings"' }),
].join(''),
}) + `
<div class="nav-list profile-more-list">
<button class="nav-row" type="button" data-profile-list="primary_given"><span class="nav-row__label">Подтверждённые мной аккаунты</span><span class="nav-row__hint">Аккаунты, за которые вы поручились</span></button>
<button class="nav-row" type="button" data-profile-list="shine_given"><span class="nav-row__label">Отмеченные мной как сияющие</span></button>
<button class="nav-row" type="button" data-self-profile-action="profiles"><span class="nav-row__label">Сменить профиль</span><span class="nav-row__hint">Другие аккаунты на этом устройстве</span></button>
</div>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
@@ -198,31 +78,10 @@ export function render({ navigate, chrome }) {
if (action === 'edit') navigate('profile-edit-view');
if (action === 'wallet') navigate('wallet-view');
if (action === 'settings') navigate('settings-view');
if (action === 'profiles') navigate('profiles-view');
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (!detailButton) return;
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#profile-view-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
});
async function refresh() {
+14 -15
View File
@@ -1,10 +1,10 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js';
import {
closeAllSavedProfiles,
closeSavedProfile,
getSavedProfiles,
prepareAddProfileLogin,
state,
switchToSavedProfile,
} from '../state.js';
@@ -58,12 +58,12 @@ export function render({navigate, chrome}) {
const closeAllButton = document.createElement('button');
closeAllButton.type = 'button';
closeAllButton.className = 'secondary-btn profiles-close-all';
closeAllButton.textContent = 'Закрыть все профили';
closeAllButton.className = 'destructive-btn profiles-close-all';
closeAllButton.textContent = 'Выйти из всех профилей';
closeAllButton.addEventListener('click', async () => {
const profiles = getSavedProfiles();
if (!profiles.length) return;
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
const confirmed = await confirmDialog({ title: 'Выйти из всех профилей?', text: 'Все профили на этом устройстве будут закрыты, откроется экран входа.', confirmLabel: 'Выйти', danger: true });
if (!confirmed) return;
closeAllButton.disabled = true;
status.hidden = false;
@@ -83,10 +83,8 @@ export function render({navigate, chrome}) {
const renderList = () => {
const profiles = getSavedProfiles();
const active = profiles.find((item) => item.isActive);
intro.textContent = profiles.length
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
: 'На устройстве нет сохранённых профилей.';
intro.textContent = profiles.length ? '' : 'На устройстве нет сохранённых профилей.';
intro.hidden = profiles.length > 0;
closeAllButton.disabled = profiles.length === 0;
list.innerHTML = '';
@@ -97,11 +95,11 @@ export function render({navigate, chrome}) {
const select = document.createElement('button');
select.type = 'button';
select.className = 'profiles-select';
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="profiles-active-badge">Сейчас открыт</span>' : ''}`;
select.disabled = profile.isActive;
select.addEventListener('click', async () => {
if (profile.isActive) return;
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
const confirmed = await confirmDialog({ title: 'Переключить профиль?', text: `Откроется профиль «${profile.login}».`, confirmLabel: 'Переключить' });
if (!confirmed) return;
status.hidden = false;
status.className = 'status-line';
@@ -118,16 +116,17 @@ export function render({navigate, chrome}) {
const close = document.createElement('button');
close.type = 'button';
close.className = 'profiles-close';
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
close.setAttribute('aria-label', `Выйти из профиля ${profile.login}`);
close.title = 'Выйти из профиля';
close.textContent = '×';
close.addEventListener('click', async () => {
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
const message = profile.isActive
? (others.length
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
if (!window.confirm(message)) return;
? 'Приложение переключится на следующий сохранённый профиль.'
: 'После выхода откроется экран входа.')
: 'Профиль будет закрыт на этом устройстве.';
if (!await confirmDialog({ title: `Выйти из профиля «${profile.login}»?`, text: message, confirmLabel: 'Выйти', danger: true })) return;
status.hidden = false;
status.className = 'status-line';
@@ -247,10 +247,10 @@ function queueSeedFor(queueId) {
function styleInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = 'var(--surface)';
}
function readTicketFromUrl() {
+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'}`;
+34 -49
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 {
@@ -13,7 +14,7 @@ import {
} from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
export const pageMeta = { id: 'register-view', title: 'Регистрация', showAppChrome: false };
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
function normalizeLoginForTemporaryUiGuard(login) {
@@ -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';
@@ -132,13 +123,27 @@ export function render({ navigate }) {
}
function updatePasswordLength() {
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
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 = '';
@@ -425,7 +410,7 @@ export function render({ navigate }) {
screen.append(
createTopBar({
title: 'Зарегистрироваться',
title: 'Регистрация',
back: {
label: '←',
onClick: () => {
@@ -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)),
}));
}
+18 -16
View File
@@ -10,7 +10,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
],
},
@@ -26,7 +26,7 @@ export const REGISTRATION_FAQ_TOPICS = [
},
{
id: 'key-derivation',
shortTitle: 'Деривация',
shortTitle: 'Как пароль превращается в ключи',
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
@@ -39,9 +39,9 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
],
},
@@ -78,9 +78,9 @@ export const REGISTRATION_FAQ_TOPICS = [
{
id: 'first-server',
shortTitle: 'Первый сервер',
title: 'Что такое первый сервер SHiNE?',
title: 'Что такое первый сервер Сияния?',
paragraphs: [
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Первый сервер Сияния это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
],
},
{
@@ -118,7 +118,7 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Кошелёк',
title: 'Можно ли использовать такое устройство как кошелёк?',
paragraphs: [
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'Да. Идея Сияния в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
],
@@ -142,7 +142,6 @@ export function render({ navigate }) {
const heroCard = document.createElement('div');
heroCard.className = 'card stack registration-faq-hero';
heroCard.innerHTML = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`;
@@ -174,20 +173,23 @@ export function render({ navigate }) {
}
const topicsCard = document.createElement('div');
topicsCard.className = 'card stack';
topicsCard.className = 'stack';
const topicsLabel = document.createElement('p');
topicsLabel.className = 'field-label';
topicsLabel.className = 'nav-list__title';
topicsLabel.textContent = 'Другие вопросы';
const topicsGrid = document.createElement('div');
topicsGrid.className = 'registration-faq-grid';
topicsGrid.className = 'nav-list';
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
REGISTRATION_FAQ_TOPICS.filter((topic) => topic.id !== selectedTopic.id).forEach((topic) => {
const button = document.createElement('button');
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
button.className = 'nav-row';
button.type = 'button';
button.textContent = topic.shortTitle;
const label = document.createElement('span');
label.className = 'nav-row__label';
label.textContent = topic.title;
button.append(label);
button.addEventListener('click', () => {
state.registrationHelp.selectedTopic = topic.id;
navigate('registration-faq-view');
+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',
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
);
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, 'Не удалось показать кошелёк. Попробуйте открыть экран ещё раз.');
}
})();
+7 -11
View File
@@ -6,7 +6,7 @@ export const pageMeta = { id: 'server-settings-view', title: 'Серверы б
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
@@ -27,13 +27,9 @@ export function render({navigate, chrome}) {
const timers = new Map();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.className = 'settings-intro';
introCard.innerHTML = `
<p class="field-label">Серверы блокчейнов и публичных данных</p>
<p class="meta-muted">
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
Эти настройки не меняют список личных серверов доступа пользователя.
</p>
<p class="meta-muted">Откуда приложение читает публичные данные: Solana, Сияние и Arweave. Личные серверы доступа здесь не меняются.</p>
`;
const body = document.createElement('div');
@@ -138,7 +134,7 @@ export function render({navigate, chrome}) {
const callTimeoutLabel = document.createElement('label');
callTimeoutLabel.className = 'field-label';
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
callTimeoutLabel.textContent = 'Сколько ждать связи с сервером перед звонком (мс)';
const callTimeoutInput = document.createElement('input');
callTimeoutInput.className = 'input';
@@ -155,13 +151,13 @@ export function render({navigate, chrome}) {
const callTimeoutHint = document.createElement('p');
callTimeoutHint.className = 'meta-muted';
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callTimeoutHint.textContent = 'Перед звонком приложение проверяет и при необходимости восстанавливает соединение с сервером. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
body.append(callSettings);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.className = 'action-pair';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
@@ -190,7 +186,7 @@ export function render({navigate, chrome}) {
help.textContent = '?';
help.addEventListener('click', () => {
window.alert(
'Текст для разработчиков: для SHiNE вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
'Текст для разработчиков: для Сияния вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
);
});
+61 -55
View File
@@ -1,7 +1,9 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
import { getThemeMode, resolveThemeMode, setThemeMode } from '../services/theme-service.js';
import { openPaletteEditor } from '../components/palette-editor.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -32,68 +34,69 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
let isDisposed = false;
chrome?.setTopbar(createTopBar({
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', 'Логотип SHiNE');
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');
});
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'stack settings-sections';
const row = (id, label, hint) => `<button class="nav-row" type="button" id="${id}"><span class="nav-row__label">${label}</span>${hint ? `<span class="nav-row__hint">${hint}</span>` : ''}</button>`;
card.innerHTML = `
<div class="stack">
<label for="settings-theme"><strong>Оформление</strong></label>
<span class="meta-muted">Тема меняет только цвета и не перезагружает экран.</span>
<select class="select" id="settings-theme">
<option value="system">Как на устройстве</option>
<option value="light">Дневное</option>
<option value="dark">Ночное</option>
</select>
<div class="card stack settings-theme-card">
<strong id="settings-theme-label">Оформление</strong>
<div class="tabs tabs--auto" id="settings-theme" role="radiogroup" aria-labelledby="settings-theme-label">
<button type="button" class="tab-btn" role="radio" data-mode="system">Авто</button>
<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>
<button type="button" class="secondary-btn settings-theme-editor" id="settings-theme-editor">Настроить цвета</button>
</div>
<div class="nav-list">
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
${row('settings-remote-addblock', 'Публикация через домашний сервер', 'Когда ключа публикаций нет на этом устройстве')}
${row('settings-language', 'Язык / Language', 'Русский')}
</div>
<div class="nav-list">
<button class="nav-row nav-row--danger" type="button" id="settings-signout"><span class="nav-row__label">Выйти из аккаунта на этом устройстве</span></button>
</div>
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
<span style="display:block; text-align:left;">
<strong>Сервер доступа</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers">
<span style="display:block; text-align:left;">
<strong>Серверы блокчейнов</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузить файлы в блокчейн</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
const themeSelect = card.querySelector('#settings-theme');
themeSelect.value = getThemeMode();
themeSelect.addEventListener('change', () => setThemeMode(themeSelect.value));
const themeSwitch = card.querySelector('#settings-theme');
const syncThemeSwitch = () => {
const mode = getThemeMode();
themeSwitch.querySelectorAll('[data-mode]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.mode === mode));
});
};
syncThemeSwitch();
themeSwitch.addEventListener('click', (event) => {
const mode = event.target.closest('[data-mode]')?.dataset.mode;
if (!mode) return;
setThemeMode(mode);
syncThemeSwitch();
});
card.querySelector('#settings-theme-editor').addEventListener('click', () => {
openPaletteEditor({ theme: resolveThemeMode() });
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
@@ -107,9 +110,12 @@ export function render({navigate, chrome}) {
const signOutBtn = card.querySelector('#settings-signout');
signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm(
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
);
const confirmed = await confirmDialog({
title: 'Выйти из аккаунта?',
text: 'Вы выйдете на этом устройстве. Если сохранены другие профили, приложение переключится на следующий.',
confirmLabel: 'Выйти',
danger: true,
});
if (!confirmed) return;
signOutBtn.disabled = true;
@@ -126,7 +132,7 @@ export function render({navigate, chrome}) {
}
});
screen.append(logoButton, card);
screen.append(card);
if (isDeveloperToolsEnabled()) {
const versionCard = document.createElement('div');
+15 -36
View File
@@ -1,10 +1,11 @@
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';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
export const pageMeta = { id: 'show-keys-view', title: 'Ключи этого устройства' };
export function render({navigate, chrome}) {
const screen = document.createElement('section');
@@ -23,38 +24,26 @@ export function render({navigate, chrome}) {
};
chrome?.setTopbar(createTopBar({
title: 'Показать ключи',
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'nav-list key-list';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...';
card.append(status);
const renderField = (id, label) => {
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}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
</div>
<div class="key-value key-value--compact" data-value="${id}">*****</div>
`;
@@ -64,9 +53,9 @@ export function render({navigate, chrome}) {
};
card.append(
renderField('root', 'root key (base58)'),
renderField('blockchain', 'blockchain.key (base58)'),
renderField('device', 'client key (base58)'),
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
);
const setMissingState = (id) => {
@@ -91,8 +80,8 @@ export function render({navigate, chrome}) {
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.innerHTML = visible[id]
? field?._eyeIcon || ''
: field?._eyeOffIcon || '';
? field?._eyeOffIcon || ''
: field?._eyeIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
};
@@ -108,16 +97,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const closeButton = document.createElement('button');
closeButton.className = 'ghost-btn';
closeButton.type = 'button';
closeButton.textContent = 'Назад';
closeButton.addEventListener('click', () => navigate('device-view'));
actions.append(closeButton);
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
@@ -149,6 +128,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
})();
screen.append(card, actions);
screen.append(status, card);
return screen;
}
+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');
+10 -5
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';
@@ -31,13 +36,13 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button');
registerButton.className = 'shine-btn shine-btn--register';
registerButton.className = 'secondary-btn shine-btn--register';
registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view'));
const languageButton = document.createElement('button');
languageButton.className = 'shine-btn shine-btn--view';
languageButton.className = 'text-btn start-language-link';
languageButton.type = 'button';
languageButton.textContent = 'Язык / Language';
languageButton.addEventListener('click', () => {
@@ -46,9 +51,9 @@ export function render({ navigate }) {
});
const settingsButton = document.createElement('button');
settingsButton.className = 'shine-btn shine-btn--settings';
settingsButton.className = 'text-btn start-settings-link';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки';
settingsButton.textContent = 'Настройки входа';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, languageButton, settingsButton);
@@ -3,7 +3,7 @@ import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' };
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Вход с другого устройства' };
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
@@ -38,15 +38,14 @@ export function render({navigate, chrome}) {
status.style.display = 'none';
const actions = document.createElement('div');
actions.className = 'row';
actions.style.flexWrap = 'wrap';
actions.className = 'stack trusted-login-actions';
const enableToggleBtn = document.createElement('button');
enableToggleBtn.className = 'primary-btn';
enableToggleBtn.type = 'button';
const noPasswordBtn = document.createElement('button');
noPasswordBtn.className = 'ghost-btn';
noPasswordBtn.className = 'secondary-btn';
noPasswordBtn.type = 'button';
noPasswordBtn.textContent = 'Сделать вход без пароля';
@@ -77,6 +76,7 @@ export function render({navigate, chrome}) {
busy = flag;
enableToggleBtn.disabled = flag;
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
noPasswordBtn.hidden = !settings.enabled || !settings.hasPassword;
savePasswordBtn.disabled = flag;
passwordInput.disabled = flag;
passwordConfirmInput.disabled = flag;
@@ -87,6 +87,7 @@ export function render({navigate, chrome}) {
enableToggleBtn.textContent = settings.enabled
? 'Запретить вход через другое устройство'
: 'Разрешить вход через другое устройство';
enableToggleBtn.className = settings.enabled ? 'destructive-btn' : 'primary-btn';
actions.innerHTML = '';
actions.append(enableToggleBtn);
@@ -183,7 +184,7 @@ export function render({navigate, chrome}) {
});
chrome?.setTopbar(createTopBar({
title: 'Настройки входа через устройство',
title: 'Вход с другого устройства',
back: { label: '←', onClick: () => navigate('device-pairing-view') },
}));
screen.append(
+13 -139
View File
@@ -5,6 +5,7 @@ import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'user', title: 'Профиль' };
@@ -24,68 +25,6 @@ function effectiveSocial(flags = {}) {
return 'none';
}
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
function addIconHtml() {
return `
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
<path d="M10.5 20.5 17 27l13-14" />
</svg>`;
}
function relationMenuHtml(flags = {}) {
const current = effectiveSocial(flags);
const rows = [
@@ -107,7 +46,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack user-profile-screen';
const header = createTopBar({
title: requestedLogin || 'Профиль',
title: 'Профиль',
back: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
@@ -186,60 +125,20 @@ export function render({ navigate, route, chrome }) {
function renderProfile() {
if (!card) return;
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login;
body.innerHTML = `
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
</div>
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
</div>
</div>` : ''}`;
body.innerHTML = profileCardHtml({
card,
login: requestedLogin,
isSelf,
beforeTilesHtml: '<div class="user-profile-add-menu" hidden></div>',
tilesHtml: isSelf ? '' : [
profileTileHtml({ icon: 'user-plus', label: 'Добавить', attrs: 'data-profile-action="add" aria-haspopup="menu" aria-expanded="false"' }),
profileTileHtml({ label: 'Связи', attrs: 'data-profile-action="links"', iconMarkup: `<img class="pf-tile-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">` }),
profileTileHtml({ icon: 'message', label: 'Написать', attrs: 'data-profile-action="chat"' }),
].join(''),
});
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
@@ -272,31 +171,6 @@ export function render({ navigate, route, chrome }) {
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (detailButton) {
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#user-profile-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
return;
}
const relationButton = event.target.closest('[data-relation-kind]');
if (relationButton) {
if (!selfLogin) {
+81 -108
View File
@@ -531,10 +531,10 @@ export function render({navigate, chrome}) {
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = 'var(--surface)';
}
function renderSupportHub() {
@@ -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);
@@ -1283,55 +1283,28 @@ export function render({navigate, chrome}) {
content.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0 0 6px;">Кошелёк</h2>
<p class="meta-muted">Выберите режим кошелька.</p>
`;
const solanaBtn = document.createElement('button');
solanaBtn.className = 'primary-btn';
solanaBtn.style.width = '100%';
solanaBtn.textContent = 'Solana кошелёк';
solanaBtn.addEventListener('click', () => {
void renderSolanaWallet();
card.className = 'nav-list';
const items = [
['Solana кошелёк', 'Баланс SOL', () => renderSolanaWallet()],
['Arweave кошелёк', 'Баланс AR', () => renderArweaveWallet()],
['Предоплаченное место Сияния', 'Лимит, израсходовано и остаток', () => renderShineBlockchainWallet()],
['Закрепление в Solana', 'Что закреплено в Solana и на сервере', () => renderSolanaPublishWallet()],
['Поддержать проект Сияние', 'Билеты и справка', () => renderSupportHub()],
];
const buttons = items.map(([label, hint, open]) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'nav-row';
btn.innerHTML = `<span class="nav-row__label"></span><span class="nav-row__hint"></span>`;
btn.querySelector('.nav-row__label').textContent = label;
btn.querySelector('.nav-row__hint').textContent = hint;
btn.addEventListener('click', () => { void open(); });
return btn;
});
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
const shineBchBtn = document.createElement('button');
shineBchBtn.className = 'primary-btn';
shineBchBtn.style.width = '100%';
shineBchBtn.textContent = 'Предоплаченное место Сияния';
shineBchBtn.addEventListener('click', () => {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
supportBtn.textContent = 'Поддержать проект Сияние';
supportBtn.addEventListener('click', () => {
void renderSupportHub();
});
const [solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn] = buttons;
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
setStatus('');
}
async function renderShineBlockchainWallet() {
@@ -1351,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';
@@ -1378,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,
@@ -1447,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) {
@@ -1530,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');
@@ -1540,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>
`;
@@ -1554,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');
@@ -1658,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';
@@ -1675,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 || '');
@@ -1881,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}`;
@@ -1895,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');
@@ -2027,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}`;
@@ -2041,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');
@@ -2051,7 +2024,7 @@ export function render({navigate, chrome}) {
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
@@ -2160,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);
+10 -1
View File
@@ -476,7 +476,17 @@ export function navigateBack() {
window.history.back();
}
// Чужой профиль открывают из чатов, связей и каналов — подсвечиваем вкладку, откуда пришли.
let lastToolbarTab = 'messages-list';
export function resolveToolbarActive(pageId) {
if (pageId === 'user') return lastToolbarTab;
const tab = resolveToolbarTab(pageId);
lastToolbarTab = tab;
return tab;
}
function resolveToolbarTab(pageId) {
if (
pageId === 'messages-list'
|| pageId === 'channels-list'
@@ -510,7 +520,6 @@ export function resolveToolbarActive(pageId) {
) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'user') return 'messages-list';
return 'profile-view';
}
+15 -60
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,61 +178,6 @@ function stopVideos(root) {
});
}
function createDownloadLink(url, label = 'Скачать') {
const link = document.createElement('a');
link.className = 'message-attachment-download';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.download = '';
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';
@@ -269,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';
@@ -277,16 +223,17 @@ 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) => {
img.addEventListener('load', () => {
frame.classList.add('is-loaded');
const naturalWidth = Number(img.naturalWidth || 0);
const naturalHeight = Number(img.naturalHeight || 0);
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
@@ -326,13 +273,13 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
video.muted = true;
video.playsInline = true;
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
video.addEventListener('loadeddata', () => frame.classList.add('is-loaded'), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
play.textContent = '▶';
frame.append(video, play);
}
frame.append(createDownloadLink(url));
return frame;
}
@@ -374,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];
@@ -386,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();
+68 -1
View File
@@ -28,9 +28,74 @@ function pickUnit(seconds) {
return ['year', Math.round(years)];
}
// Время поста внутри дня: «14:32». День показывается отдельной плашкой.
export function formatClockTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
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);
if (!ts) return '';
const dt = new Date(ts);
const today = new Date();
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const diffDays = Math.round((startOf(today) - startOf(dt)) / 86400000);
if (diffDays === 0) return 'Сегодня';
if (diffDays === 1) return 'Вчера';
const sameYear = dt.getFullYear() === today.getFullYear();
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(dt);
}
export function dayKey(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
}
// Время в строках списков (чаты, каналы) — коротко, как в мессенджерах:
// сегодня «21:41», вчера «вчера», на этой неделе «пт», в этом году «24.09», раньше «24.09.25».
export function formatListTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (ts >= startOfToday) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(dt);
}
const dayMs = 86400000;
if (ts >= startOfToday - dayMs) return 'вчера';
if (ts >= startOfToday - 6 * dayMs) {
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short' }).format(dt);
}
if (dt.getFullYear() === now.getFullYear()) {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(dt);
}
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(dt);
}
export function formatRelativeTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '—';
if (!ts) return '–';
const now = Date.now();
const dt = new Date(ts);
@@ -39,6 +104,8 @@ export function formatRelativeTime(timestampMs) {
const ageSeconds = Math.max(0, (now - ts) / 1000);
const ageDays = ageSeconds / 86400;
if (ageSeconds < 60) return 'только что';
if (ageDays < 7) {
const [unit, value] = pickUnit(diffSeconds);
if (rtf) return rtf.format(value, unit);
+3 -3
View File
@@ -43,7 +43,7 @@ function wsUrlToHttpBase(wsUrl = '') {
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
throw new Error('Не удалось определить HTTP-адрес сервера Сияния');
}
parsed.pathname = '/';
parsed.search = '';
@@ -609,7 +609,7 @@ function suggestedPickerTypes(attachment) {
const dot = name.lastIndexOf('.');
const extension = dot >= 0 ? name.slice(dot) : '';
return [{
description: 'Файл SHiNE',
description: 'Файл Сияния',
accept: { [mime]: extension ? [extension] : [] },
}];
}
@@ -692,7 +692,7 @@ export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = n
}
export async function buildDmFileTorrentV2(attachment = {}) {
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2');
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов Сияния v2');
const context = await loadV2RootManifest(attachment);
const pieceLayer = [];
try {
@@ -55,7 +55,7 @@ export function makeKeyTransferText({ login, keys }) {
export function parseKeyTransferText(text) {
const raw = String(text || '').trim();
if (!raw.startsWith(TRANSFER_PREFIX)) {
throw new Error('Это не QR-код переноса ключей SHiNE');
throw new Error('Это не QR-код переноса ключей Сияния');
}
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
const payload = JSON.parse(json);
@@ -46,7 +46,7 @@ export async function resolveShineServerByServerLogin({ serverLogin, solanaEndpo
solanaEndpoint,
});
if (!parsed?.isServer) {
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер Сияния.`);
}
const serverAddress = normalizeHostLike(parsed?.serverAddress || '');
if (!serverAddress) {
@@ -470,7 +470,7 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
const [pda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_USER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanLogin)], usersProgram);
const info = await connection.getAccountInfo(pda, 'confirmed');
if (!info?.data) throw new Error(`PDA пользователя @${cleanLogin} не найдена`);
return { ...parseShineUserPda(new Uint8Array(info.data)), pdaAddress: pda.toBase58(), endpoint };
return { ...parseShineUserPda(new Uint8Array(info.data)), userPda: pda.toBase58(), pdaAddress: pda.toBase58(), endpoint };
}
export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) {
+280 -3
View File
@@ -1,12 +1,282 @@
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
// Выбранная палитра и личные правки цветов (для дня и ночи отдельно).
const PALETTE_KEY = 'shine-ui-palette-v1';
// Итоговые значения для каждой темы: их читает скрипт в index.html до первой отрисовки,
// чтобы при запуске не мелькала палитра по умолчанию.
const APPLIED_KEY = 'shine-ui-palette-applied-v1';
const MODES = new Set(['system', 'light', 'dark']);
let sessionMode = null;
// Роли цветов — тот же контракт, что в docs/UI-Design/DESIGN.md (раздел 4.1) и styles/main.css.
export const PALETTE_ROLES = [
{ id: 'background', label: 'Фон' },
{ id: 'surface', label: 'Поверхности: поля, меню, карточки' },
{ id: 'surface-selected', label: 'Выбранное и лёгкое выделение' },
{ id: 'text-primary', label: 'Основной текст' },
{ 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: {
background: '#12141f', surface: '#1c2031', 'surface-selected': '#272c45',
'text-primary': '#eceefa', 'text-secondary': '#a0a6c2',
'border-subtle': '#343a55', 'border-control': '#6d7396',
accent: '#a9b4ff', 'on-accent': '#151a3d', 'reaction-active': '#ff9bb3',
danger: '#ffa7a3', success: '#8fdab4', warning: '#e8c585',
},
light: {
background: '#eef0f5', surface: '#ffffff', 'surface-selected': '#dde3f5',
'text-primary': '#1e2233', 'text-secondary': '#5c6279',
'border-subtle': '#d5d9e5', 'border-control': '#8a90a8',
accent: '#4c5caa', 'on-accent': '#ffffff', 'reaction-active': '#c02a55',
danger: '#b3261e', success: '#2c6b4a', warning: '#7d5a10',
},
},
shine: {
label: 'Бирюза',
dark: {
background: '#101b20', surface: '#17272d', 'surface-selected': '#213b3a',
'text-primary': '#e8f2f1', 'text-secondary': '#98adaf',
'border-subtle': '#2a3b40', 'border-control': '#71888a',
accent: '#94e1ce', 'on-accent': '#102c27', 'reaction-active': '#f29aab',
danger: '#ffaba8', success: '#94e1ce', warning: '#e6c184',
},
light: {
background: '#faf7f0', surface: '#fffdf8', 'surface-selected': '#efe6d6',
'text-primary': '#302c25', 'text-secondary': '#706658',
'border-subtle': '#e6dfd1', 'border-control': '#978b79',
accent: '#93511e', 'on-accent': '#fffaf3', 'reaction-active': '#a43350',
danger: '#b13135', success: '#346a48', warning: '#845b14',
},
},
mono: {
label: 'Монохром',
dark: {
background: '#111212', surface: '#1c1d1d', 'surface-selected': '#2c2d2d',
'text-primary': '#f3f3f1', 'text-secondary': '#a3a3a0',
'border-subtle': '#2a2b2b', 'border-control': '#6f706e',
accent: '#f3f3f1', 'on-accent': '#111212', 'reaction-active': '#ff8a9a',
danger: '#ff9d97', success: '#9fd8a8', warning: '#e8c37e',
},
light: {
background: '#ffffff', surface: '#f6f6f5', 'surface-selected': '#ebebea',
'text-primary': '#141414', 'text-secondary': '#626262',
'border-subtle': '#e4e4e2', 'border-control': '#8c8c8a',
accent: '#141414', 'on-accent': '#ffffff', 'reaction-active': '#d0214a',
danger: '#b3261e', success: '#2f6b3b', warning: '#7d5a10',
},
},
};
function mixHex(first, second, amount) {
const a = first.match(/[0-9a-f]{2}/gi).map((part) => parseInt(part, 16));
const b = second.match(/[0-9a-f]{2}/gi).map((part) => parseInt(part, 16));
return `#${a.map((value, index) => Math.round(value * (1 - amount) + b[index] * amount).toString(16).padStart(2, '0')).join('')}`;
}
const EXTRA_PRESETS = [
['ocean', 'Океан', '#101c2b', '#182a3d', '#213c55', '#edf5ff', '#a6b9cf', '#75c8f0', '#102b40', '#f9fcff', '#e4f2fb', '#173247', '#536f83', '#087eae', '#ffffff'],
['lavender', 'Лаванда', '#211b2b', '#30263d', '#473658', '#f5efff', '#bcb0cf', '#c5a0f5', '#f1ebf7', '#fffaff', '#e9def3', '#31283a', '#6d6076', '#7950a8', '#ffffff'],
['graphite', 'Графит', '#17191c', '#222529', '#34383d', '#f2f4f5', '#adb4ba', '#c4cbd1', '#eef0f1', '#ffffff', '#e0e4e6', '#202427', '#5b646a', '#46535b', '#ffffff'],
['sakura', 'Сакура', '#291b25', '#382430', '#503140', '#fff0f5', '#c9aab7', '#f39abb', '#fff0f4', '#fffafd', '#f7dce6', '#39232d', '#795967', '#b73565', '#ffffff'],
['emerald', 'Изумруд', '#10231e', '#19332b', '#24483a', '#e8f8ef', '#a0c5ae', '#7fe0aa', '#eaf5ed', '#fbfffc', '#d8eddf', '#20372a', '#587363', '#187747', '#ffffff'],
['sunrise', 'Рассвет', '#2b2017', '#3b2b1c', '#58402a', '#fff4df', '#d0b894', '#ffc36b', '#fff3df', '#fffaf0', '#f5e4c4', '#3a2b1b', '#78664c', '#a45c12', '#ffffff'],
['cyberpunk', 'Киберпанк', '#171329', '#241b3b', '#382657', '#f5f0ff', '#b9a9d8', '#ff70dc', '#f6edfb', '#fffbff', '#eddcf5', '#30233b', '#705b7b', '#a12b91', '#ffffff'],
['arctic', 'Арктика', '#14242a', '#1d333a', '#294750', '#edfaff', '#a7c4cc', '#82e2e5', '#e8f7f8', '#fbffff', '#d6eef0', '#20373a', '#587477', '#087f83', '#ffffff'],
['coral', 'Коралл', '#2b1d1b', '#3a2925', '#563b34', '#fff1ec', '#d1b2a8', '#ff9d83', '#fff0e9', '#fffaf7', '#f9dfd4', '#3b2923', '#7b6258', '#bc4931', '#ffffff'],
['grape', 'Виноград', '#211829', '#302238', '#493151', '#f8efff', '#c1a9cd', '#d39afa', '#f4eafa', '#fffaff', '#e9d8f2', '#35253d', '#725d7a', '#8145a9', '#ffffff'],
['forest', 'Лес', '#19231b', '#263328', '#394b39', '#f0f7ed', '#b1c1a7', '#b0d17c', '#f0f4e8', '#fcfff7', '#e4ebd7', '#303828', '#69735d', '#52751f', '#ffffff'],
['ruby', 'Рубин', '#28181e', '#382128', '#512e39', '#fff0f2', '#ceabb3', '#fa8095', '#fff0f1', '#fffafb', '#f4dce0', '#3c242b', '#795861', '#b52d4d', '#ffffff'],
['lagoon', 'Лагуна', '#102323', '#193333', '#254949', '#e9f8f4', '#a1c4bc', '#67d8c2', '#e7f5ef', '#fbfffc', '#d3eee4', '#1e3932', '#56746b', '#087e70', '#ffffff'],
['copper', 'Медь', '#281e19', '#392a22', '#533d30', '#fff2e8', '#ceb5a2', '#e9a879', '#f8eee5', '#fffaf5', '#eddfd1', '#392a21', '#786657', '#a65e2b', '#ffffff'],
['neon', 'Неон', '#141f1b', '#1e3028', '#2b4939', '#edfff2', '#a6c6ae', '#a7f36b', '#eff7e9', '#fcfff8', '#dff0d0', '#253923', '#5b7352', '#4c861d', '#ffffff'],
['paper', 'Бумага', '#20201e', '#2e2e2b', '#454540', '#f6f5ef', '#bcbcb2', '#d6c49b', '#f3f0e8', '#fffdf7', '#e8e2d3', '#302e28', '#716d61', '#76613b', '#ffffff'],
['cosmos', 'Космос', '#171a30', '#222744', '#323b62', '#f0f2ff', '#aeb6d6', '#9eacff', '#edf0fc', '#fbfcff', '#dfe4fa', '#252b46', '#5c6687', '#4f61c5', '#ffffff'],
['mint', 'Мята', '#14231f', '#1f332c', '#2e4940', '#ebfbf4', '#a9c6b9', '#8fe2c0', '#eaf5ee', '#fbfffc', '#d7eee1', '#21392f', '#597568', '#16805b', '#ffffff'],
['sandstone', 'Песчаник', '#272019', '#382e23', '#514333', '#fff5e8', '#c9b69c', '#f0bd78', '#f6efe3', '#fffdf8', '#e9deca', '#3a3023', '#776b58', '#96601b', '#ffffff'],
['dusk', 'Сумерки', '#211b2a', '#30263c', '#493658', '#f5efff', '#b8acc8', '#e0a6c8', '#f2edf4', '#fffaff', '#e8dfe9', '#352a39', '#706174', '#98577d', '#ffffff'],
['lemon', 'Лимон', '#202216', '#30331e', '#454a2b', '#f8f9e9', '#bfc39c', '#e2e96d', '#f2f3e3', '#fdfff5', '#e6e9cc', '#353822', '#71755a', '#737c16', '#ffffff'],
['obsidian', 'Обсидиан', '#151719', '#202326', '#30363b', '#f2f5f7', '#aab5bc', '#8db7c7', '#e9eef0', '#fbfeff', '#dce5e8', '#252c30', '#5c6a70', '#3b778c', '#ffffff'],
];
function makePreset(label, dark, light) {
const toColors = (palette, theme) => {
const [background, surface, ...rest] = palette;
const [selected, text, secondary, accent, onAccent] = rest.length === 5
? rest
: [mixHex(surface, rest[0], 0.08), ...rest];
return {
background,
surface,
'surface-selected': selected,
'text-primary': text,
'text-secondary': secondary,
'border-subtle': mixHex(surface, text, 0.14),
'border-control': mixHex(surface, text, 0.4),
accent,
'on-accent': onAccent,
'reaction-active': theme === 'dark' ? '#ff9bb3' : '#c02a55',
danger: theme === 'dark' ? '#ffa7a3' : '#b3261e',
success: theme === 'dark' ? '#8fdab4' : '#2c6b4a',
warning: theme === 'dark' ? '#e8c585' : '#7d5a10',
};
};
return { label, dark: toColors(dark, 'dark'), light: toColors(light, 'light') };
}
for (const [id, label, ...colors] of EXTRA_PRESETS) {
PALETTE_PRESETS[id] = makePreset(label, colors.slice(0, 7), colors.slice(7));
}
const DEFAULT_PRESET = 'aurora';
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
function normalizeMode(value) {
const mode = String(value || '').trim().toLowerCase();
return MODES.has(mode) ? mode : 'system';
}
function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || 'null');
} catch {
return null;
}
}
function writeJson(key, value) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify(value));
} catch {
// В приватном режиме палитра всё равно действует до закрытия страницы.
}
}
function cleanOverrides(raw) {
const out = {};
for (const role of PALETTE_ROLES) {
const value = String(raw?.[role.id] || '').trim();
if (HEX_COLOR.test(value)) out[role.id] = value.toLowerCase();
}
return out;
}
let sessionPalette = null;
export function getPaletteSettings() {
if (sessionPalette) return sessionPalette;
const raw = readJson(PALETTE_KEY) || {};
const preset = PALETTE_PRESETS[raw.preset] ? raw.preset : DEFAULT_PRESET;
return {
preset,
custom: { dark: cleanOverrides(raw.custom?.dark), light: cleanOverrides(raw.custom?.light) },
};
}
// Роли, которых нет в старых пресетах, выводятся из основных цветов.
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 withDerivedRoles({ ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] }, theme);
}
function isDefaultPalette(settings) {
return settings.preset === DEFAULT_PRESET
&& !Object.keys(settings.custom.dark).length
&& !Object.keys(settings.custom.light).length;
}
function applyPaletteVars(resolvedTheme) {
const root = document.documentElement;
const settings = getPaletteSettings();
for (const role of PALETTE_ROLES) root.style.removeProperty(`--${role.id}`);
root.style.removeProperty('--focus-ring');
// Палитра по умолчанию живёт в styles/main.css — inline-переменные не нужны.
if (isDefaultPalette(settings)) return;
const colors = resolvePalette(resolvedTheme, settings);
for (const [role, value] of Object.entries(colors)) root.style.setProperty(`--${role}`, value);
root.style.setProperty('--focus-ring', colors.accent);
}
export function setPaletteSettings(next) {
const settings = {
preset: PALETTE_PRESETS[next?.preset] ? next.preset : DEFAULT_PRESET,
custom: { dark: cleanOverrides(next?.custom?.dark), light: cleanOverrides(next?.custom?.light) },
};
sessionPalette = settings;
if (isDefaultPalette(settings)) {
writeJson(PALETTE_KEY, null);
writeJson(APPLIED_KEY, null);
} else {
writeJson(PALETTE_KEY, settings);
writeJson(APPLIED_KEY, { dark: resolvePalette('dark', settings), light: resolvePalette('light', settings) });
}
return applyThemeMode();
}
export function exportPalette() {
const settings = getPaletteSettings();
return JSON.stringify({
preset: settings.preset,
dark: resolvePalette('dark', settings),
light: resolvePalette('light', settings),
}, null, 2);
}
export function importPalette(text) {
const data = JSON.parse(String(text || ''));
const preset = PALETTE_PRESETS[data?.preset] ? data.preset : DEFAULT_PRESET;
const base = PALETTE_PRESETS[preset];
const diff = (theme) => {
const colors = cleanOverrides(data?.[theme]);
return Object.fromEntries(Object.entries(colors).filter(([role, value]) => base[theme][role] !== value));
};
return setPaletteSettings({ preset, custom: { dark: diff('dark'), light: diff('light') } });
}
export function getThemeMode() {
if (sessionMode !== null) return sessionMode;
try {
@@ -16,14 +286,21 @@ export function getThemeMode() {
}
}
export function resolveThemeMode(mode = getThemeMode()) {
const normalized = normalizeMode(mode);
if (normalized !== 'system') return normalized;
return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark';
}
export function applyThemeMode(mode = getThemeMode()) {
const normalized = normalizeMode(mode);
const resolved = normalized === 'system'
? (window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark')
: normalized;
const resolved = resolveThemeMode(normalized);
document.documentElement.dataset.themeMode = normalized;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
applyPaletteVars(resolved);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', resolvePalette(resolved).background);
return { mode: normalized, resolved };
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
{ key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' },
{ key: 'web', readKeys: ['web'], label: 'Ссылки', placeholder: 'Сайт, профиль или другая ссылка' },
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
+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) {
+55 -64
View File
@@ -3,7 +3,7 @@
* Feature-specific стили находятся в styles/features/* и network-graph.css.
*/
.app-shell {
width: min(var(--app-viewport-width, 100vw), var(--shell-content-width, 430px));
width: min(var(--app-viewport-width, 100vw), var(--shell-content-width, 640px));
height: var(--app-viewport-height, 100vh);
position: fixed;
top: var(--app-viewport-offset-top, 0px);
@@ -18,13 +18,17 @@
--z-shell-fade: 10;
--z-shell-chrome: 20;
--z-shell-status: 30;
background: transparent;
background: var(--background);
border-left: 1px solid transparent;
border-right: 1px solid transparent;
box-shadow: none;
overflow: hidden;
}
@media (min-width: 700px) {
.app-shell { border-color: var(--border-subtle); }
}
.screen-content {
position: absolute;
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
@@ -45,7 +49,7 @@
}
.app-shell.has-minimized-call .screen-content {
padding-top: 12px;
padding-top: var(--page-top-gap);
}
.topbar-slot,
@@ -65,10 +69,12 @@
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
padding: 0 12px;
padding: 0;
pointer-events: none;
}
.topbar-slot > .topbar { padding-inline: 12px; }
.topbar-slot > * {
pointer-events: auto;
}
@@ -104,43 +110,28 @@
right: 12px;
bottom: calc(96px + env(safe-area-inset-bottom));
z-index: var(--z-shell-status);
border-radius: 11px;
border: 1px solid rgba(133, 156, 201, 0.3);
background: rgba(10, 19, 37, 0.86);
color: #c6d6f7;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface);
color: var(--text-primary);
box-shadow: var(--shadow);
font-size: 12px;
line-height: 1.2;
text-align: center;
padding: 7px 10px;
padding: 8px 12px;
pointer-events: auto;
cursor: pointer;
backdrop-filter: blur(10px);
}
.connection-retry-banner.is-connected {
border-color: rgba(124, 235, 171, 0.4);
color: #d8ffe9;
}
.connection-retry-banner.is-connecting {
border-color: transparent;
color: #ffe8bb;
}
.connection-retry-banner.is-disconnected {
border-color: rgba(228, 127, 145, 0.44);
color: #ffdce3;
}
.connection-retry-banner.is-updating {
border-color: rgba(144, 201, 255, 0.44);
color: #d9eeff;
}
.connection-retry-banner.is-connected { border-color: var(--success); }
.connection-retry-banner.is-connecting { color: var(--text-secondary); }
.connection-retry-banner.is-disconnected { border-color: var(--danger); color: var(--danger); }
.connection-retry-banner.is-updating { border-color: var(--accent); }
/* Обычный 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. */
@@ -149,10 +140,10 @@
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
width: min(100vw, 430px);
width: min(100vw, var(--shell-content-width, 640px));
margin: 0 auto;
transform: none;
padding: 0 12px;
padding: 0;
}
.app-shell--scroll-nested .screen-content {
@@ -201,13 +192,13 @@
height: calc(var(--topbar-height, 64px) + 48px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 1) 0%,
rgba(5, 7, 10, 0.98) 16%,
rgba(5, 7, 10, 0.88) 32%,
rgba(5, 7, 10, 0.66) 52%,
rgba(5, 7, 10, 0.38) 70%,
rgba(5, 7, 10, 0.15) 84%,
rgba(5, 7, 10, 0) 100%
color-mix(in srgb, var(--background) 100%, transparent) 0%,
color-mix(in srgb, var(--background) 98%, transparent) 16%,
color-mix(in srgb, var(--background) 88%, transparent) 32%,
color-mix(in srgb, var(--background) 66%, transparent) 52%,
color-mix(in srgb, var(--background) 38%, transparent) 70%,
color-mix(in srgb, var(--background) 15%, transparent) 84%,
color-mix(in srgb, var(--background) 0%, transparent) 100%
);
}
@@ -217,13 +208,13 @@
height: calc(var(--composer-height, 0px) + 52px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 0) 0%,
rgba(5, 7, 10, 0.14) 18%,
rgba(5, 7, 10, 0.38) 38%,
rgba(5, 7, 10, 0.66) 58%,
rgba(5, 7, 10, 0.88) 76%,
rgba(5, 7, 10, 0.98) 90%,
rgba(5, 7, 10, 1) 100%
color-mix(in srgb, var(--background) 0%, transparent) 0%,
color-mix(in srgb, var(--background) 14%, transparent) 18%,
color-mix(in srgb, var(--background) 38%, transparent) 38%,
color-mix(in srgb, var(--background) 66%, transparent) 58%,
color-mix(in srgb, var(--background) 88%, transparent) 76%,
color-mix(in srgb, var(--background) 98%, transparent) 90%,
color-mix(in srgb, var(--background) 100%, transparent) 100%
);
}
@@ -232,15 +223,15 @@
height: var(--topbar-height, 64px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 1) 0%,
rgba(5, 7, 10, 1) 32%,
rgba(5, 7, 10, 0.94) 38%,
rgba(5, 7, 10, 0.72) 46%,
rgba(5, 7, 10, 0.46) 54%,
rgba(5, 7, 10, 0.22) 61%,
rgba(5, 7, 10, 0.08) 65%,
rgba(5, 7, 10, 0) 68%,
rgba(5, 7, 10, 0) 100%
color-mix(in srgb, var(--background) 100%, transparent) 0%,
color-mix(in srgb, var(--background) 100%, transparent) 32%,
color-mix(in srgb, var(--background) 94%, transparent) 38%,
color-mix(in srgb, var(--background) 72%, transparent) 46%,
color-mix(in srgb, var(--background) 46%, transparent) 54%,
color-mix(in srgb, var(--background) 22%, transparent) 61%,
color-mix(in srgb, var(--background) 8%, transparent) 65%,
color-mix(in srgb, var(--background) 0%, transparent) 68%,
color-mix(in srgb, var(--background) 0%, transparent) 100%
);
}
@@ -250,15 +241,15 @@
height: var(--toolbar-height, 78px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 0) 0%,
rgba(5, 7, 10, 0) 32%,
rgba(5, 7, 10, 0.08) 35%,
rgba(5, 7, 10, 0.22) 39%,
rgba(5, 7, 10, 0.46) 46%,
rgba(5, 7, 10, 0.72) 54%,
rgba(5, 7, 10, 0.94) 62%,
rgba(5, 7, 10, 1) 68%,
rgba(5, 7, 10, 1) 100%
color-mix(in srgb, var(--background) 0%, transparent) 0%,
color-mix(in srgb, var(--background) 0%, transparent) 32%,
color-mix(in srgb, var(--background) 8%, transparent) 35%,
color-mix(in srgb, var(--background) 22%, transparent) 39%,
color-mix(in srgb, var(--background) 46%, transparent) 46%,
color-mix(in srgb, var(--background) 72%, transparent) 54%,
color-mix(in srgb, var(--background) 94%, transparent) 62%,
color-mix(in srgb, var(--background) 100%, transparent) 68%,
color-mix(in srgb, var(--background) 100%, transparent) 100%
);
}
+64 -39
View File
@@ -1,55 +1,80 @@
/* Семантические роли кнопок. Геометрией сложных компонентов владеют их стили. */
.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); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):disabled { opacity: .5; cursor: not-allowed; filter: none; }
/* Explicit filled-action variant used by pre-auth/settings/profile screens. */
.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) {
color: #ffffff;
background: var(--shine-action-blue);
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0));
.filled-action-buttons :is(.primary-btn, .shine-btn, .square-btn, .registration-faq-link, .profiles-select) {
color: var(--on-accent);
background: var(--accent);
border: 0;
border-radius: 14px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 8px 22px rgba(var(--shine-action-blue-rgb), 0.24);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
border-radius: var(--radius-md);
box-shadow: none;
text-shadow: none;
}
.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):hover {
background: var(--shine-action-blue-hover);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 10px 26px rgba(var(--shine-action-blue-rgb), 0.32);
filter: brightness(1.12);
}
.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 {
background: var(--shine-action-blue-pressed);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.26);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.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):focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.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(.secondary-btn, .ghost-btn, .text-btn, .profiles-close) {
color: var(--action);
background: transparent;
background-image: none;
border-color: transparent;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
box-shadow: none;
text-shadow: none;
}
.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 {
color: rgba(255, 255, 255, 0.58);
background: rgba(var(--shine-action-blue-rgb), 0.42);
box-shadow: none;
.filled-action-buttons .destructive-btn {
color: var(--danger);
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, .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, .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, .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;
}
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings {
position: relative;
width: 100%;
min-height: 52px;
padding: 10px 40px 10px 14px;
display: flex;
align-items: center;
justify-content: flex-start;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border-subtle);
font-size: var(--text-md);
font-weight: var(--weight-medium);
text-align: left;
}
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings::after {
content: '›';
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
font-size: 22px;
line-height: 1;
}
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings:hover { background: var(--surface-selected); }
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings strong { font-weight: var(--weight-bold); }
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings .meta-muted { color: var(--text-secondary); font-size: var(--text-xs); font-weight: 400; }
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings.shine-btn--danger { justify-content: center; padding-right: 14px; color: var(--danger); }
.screen-content:is(.filled-action-buttons, .settings-bordered-actions) .shine-btn.shine-btn--settings.shine-btn--danger::after { content: none; }
+58 -90
View File
@@ -14,7 +14,7 @@
.message-attachment-carousel-viewport {
align-items: center;
background: var(--surface);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: 14px;
display: grid;
@@ -137,28 +137,10 @@
}
.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.75rem;
position: absolute;
right: 0.75rem;
}
.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; } }
@media (prefers-reduced-motion: reduce) { .message-attachment-media-frame:not(.is-loaded) { animation: none; } }
.message-attachment-file-card {
@@ -279,71 +261,60 @@
.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: var(--space-3);
}
/* Пустые служебные блоки (сумма, превью, ошибка) не должны давать промежутков. */
.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: var(--control-height);
padding: 4px;
display: flex;
align-items: center;
gap: var(--space-3);
border: 1px dashed var(--border-control);
border-radius: 12px;
background: var(--surface);
cursor: pointer;
}
.ar-attachment-file-picker:hover { border-color: var(--accent); }
.ar-attachment-file-picker:focus-within { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.ar-attachment-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.ar-attachment-file-btn {
flex-shrink: 0;
min-height: 34px;
padding: 0 14px;
display: inline-flex;
align-items: center;
border-radius: 8px;
font-size: var(--text-md);
color: var(--on-accent);
background: var(--accent);
font-weight: 600;
}
.ar-attachment-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); font-size: .875rem; }
.ar-attachment-summary { color: var(--text-primary); font-size: .875rem; }
.ar-attachment-ok { color: var(--success); font-weight: 600; }
.ar-attachment-details { color: var(--text-secondary); font-size: .8125rem; line-height: 1.25rem; }
.ar-attachment-details summary { min-height: 36px; display: flex; align-items: center; color: var(--accent); cursor: pointer; }
.ar-attachment-details > div { overflow-wrap: anywhere; }
.ar-attachment-manager-card--wide {
max-width: min(96vw, 58rem);
@@ -359,10 +330,7 @@
}
.ar-attachment-wallet-select,
.ar-attachment-wallet-select option {
color: var(--accent);
}
.ar-attachment-wallet-select option { color: var(--text-primary); background: var(--surface); }
.ar-attachment-history-table-wrap {
@@ -494,7 +462,7 @@
border-radius: 999px;
bottom: 0.42rem;
color: var(--text-primary);
font-size: 0.62rem;
font-size: 0.6875rem;
left: 0.42rem;
padding: 0.2rem 0.46rem;
position: absolute;
@@ -533,7 +501,7 @@
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
font-size: 0.6rem;
font-size: 0.6875rem;
gap: 0.22rem;
line-height: 1.05;
min-width: 0;
@@ -544,7 +512,7 @@
background: var(--border-subtle);
border-radius: 0.34rem;
color: var(--text-secondary);
font-size: 0.56rem;
font-size: 0.6875rem;
line-height: 1.05;
max-width: 100%;
min-width: 0;
@@ -558,7 +526,7 @@
.ar-attachment-status {
border-radius: 999px;
flex: 0 0 auto;
font-size: 0.58rem;
font-size: 0.6875rem;
font-weight: 800;
line-height: 1;
padding: 0.2rem 0.32rem;
@@ -588,7 +556,7 @@
border-radius: 999px;
color: var(--accent);
flex: 0 0 auto;
font-size: 0.58rem;
font-size: 0.6875rem;
font-weight: 800;
line-height: 1;
padding: 0.2rem 0.32rem;
+10 -15
View File
@@ -6,9 +6,8 @@
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(155, 182, 233, 0.46);
background: rgba(13, 26, 50, 0.86);
box-shadow: inset 0 0 0 1px rgba(240, 248, 255, 0.1);
border: 1px solid var(--border-control);
background: var(--surface);
}
@@ -30,7 +29,7 @@
.avatar-wizard-meta {
font-size: 13px;
line-height: 1.35;
color: #d9e7ff;
color: var(--text-secondary);
word-break: break-word;
}
@@ -38,7 +37,7 @@
.avatar-wizard-error {
min-height: 18px;
font-size: 13px;
color: #f6a8b3;
color: var(--danger);
}
@@ -63,11 +62,11 @@
flex: 0 0 auto;
aspect-ratio: 1 / 1;
border-radius: 50%;
background: linear-gradient(130deg, #3c4f73, #243352);
background: var(--surface-selected);
display: grid;
place-items: center;
font-weight: 700;
color: #e5ebff;
font-weight: 600;
color: var(--accent);
font-size: 14px;
}
@@ -191,7 +190,7 @@
place-items: center;
border: 0;
background: transparent;
color: #ffffff;
color: var(--text-primary);
box-shadow: none;
}
@@ -211,12 +210,8 @@
display: grid;
place-items: center;
overflow: hidden;
background: #454b55;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -8px 16px rgba(0, 0, 0, 0.16);
background: var(--surface-selected);
color: var(--accent);
}
+39 -39
View File
@@ -28,8 +28,8 @@
gap: 10px;
min-height: 44px;
padding: calc(8px + env(safe-area-inset-top)) 14px 8px;
border-bottom: 1px solid rgba(159, 255, 196, 0.28);
background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98));
border-bottom: 1px solid color-mix(in srgb, var(--success) 28%, transparent);
background: linear-gradient(180deg, var(--success), var(--surface));
box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28);
pointer-events: auto;
}
@@ -42,7 +42,7 @@
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
color: var(--text-primary);
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -60,22 +60,22 @@
.call-minimized-bar-text:hover {
color: #ffffff;
color: var(--text-primary);
filter: brightness(1.12);
}
.call-minimized-bar-text:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface) 12%, transparent);
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
inset 0 -1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.call-minimized-bar-text:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline: 2px solid var(--border-control);
outline-offset: 2px;
}
@@ -109,7 +109,7 @@
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
color: var(--text-primary);
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -119,7 +119,7 @@
.call-icon-btn:hover {
color: #ffffff;
color: var(--text-primary);
background: transparent;
border: 0;
box-shadow: none;
@@ -129,12 +129,12 @@
.call-icon-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface) 12%, transparent);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
inset 0 -1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
@@ -142,7 +142,7 @@
.call-icon-btn:disabled {
opacity: 1;
color: rgba(255, 255, 255, 0.42);
color: var(--text-secondary);
background: transparent;
border: 0;
box-shadow: none;
@@ -153,7 +153,7 @@
.call-icon-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline: 2px solid var(--border-control);
outline-offset: 2px;
}
@@ -193,7 +193,7 @@
.call-overlay {
position: absolute;
inset: 0;
background: rgba(4, 8, 16, 0.88);
background: var(--surface);
backdrop-filter: blur(8px);
pointer-events: auto;
}
@@ -205,13 +205,13 @@
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(79, 124, 208, 0.24), transparent 34%),
linear-gradient(180deg, rgba(11, 20, 38, 0.98), rgba(3, 7, 15, 1));
radial-gradient(circle at 50% 18%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 34%),
linear-gradient(180deg, var(--surface), var(--surface));
}
.call-overlay-panel--remote-video {
background: #010203;
background: var(--surface);
}
@@ -234,7 +234,7 @@
min-height: 22px;
font-size: 14px;
font-weight: 700;
color: rgba(236, 242, 255, 0.94);
color: var(--text-primary);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.44);
}
@@ -252,7 +252,7 @@
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 4;
text-align: center;
color: rgba(207, 219, 246, 0.84);
color: var(--text-primary);
font-size: 13px;
pointer-events: none;
}
@@ -263,13 +263,13 @@
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 24%, rgba(54, 88, 151, 0.48), transparent 30%),
linear-gradient(180deg, rgba(9, 17, 32, 0.98), rgba(2, 5, 12, 1));
radial-gradient(circle at 50% 24%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 30%),
linear-gradient(180deg, var(--surface), var(--surface));
}
.call-video-stage--remote-video {
background: #000;
background: var(--surface);
}
@@ -279,7 +279,7 @@
width: 100%;
height: 100%;
object-fit: cover;
background: #02050a;
background: var(--surface);
}
@@ -320,8 +320,8 @@
min-width: 100%;
min-height: 100%;
border-radius: 999px;
border: 2px solid rgba(221, 233, 255, 0.2);
background: linear-gradient(180deg, rgba(18, 31, 58, 0.96), rgba(9, 15, 29, 0.98));
border: 2px solid var(--border-subtle);
background: linear-gradient(180deg, var(--surface), var(--surface));
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.34);
}
@@ -337,7 +337,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f4f8ff;
color: var(--text-primary);
font-size: clamp(20px, 5.8vw, 28px);
font-weight: 700;
letter-spacing: 0.02em;
@@ -345,7 +345,7 @@
.call-kind {
color: #ffffff;
color: var(--text-primary);
font-size: clamp(27px, 8vw, 40px);
font-weight: 800;
letter-spacing: 0.05em;
@@ -355,7 +355,7 @@
.call-hero-status {
max-width: 88vw;
color: rgba(211, 223, 246, 0.9);
color: var(--text-primary);
font-size: 14px;
line-height: 1.35;
}
@@ -370,8 +370,8 @@
aspect-ratio: 3 / 4;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(176, 205, 255, 0.38);
background: rgba(7, 12, 22, 0.94);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
background: var(--surface);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
cursor: grab;
touch-action: none;
@@ -436,7 +436,7 @@
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
color: var(--text-primary);
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -447,7 +447,7 @@
.call-accept-btn:hover {
color: #ffffff;
color: var(--text-primary);
background: transparent;
border: 0;
box-shadow: none;
@@ -457,12 +457,12 @@
.call-accept-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface) 12%, transparent);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
inset 0 -1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
@@ -470,7 +470,7 @@
.call-accept-btn:disabled {
opacity: 1;
color: rgba(255, 255, 255, 0.42);
color: var(--text-secondary);
background: transparent;
border: 0;
box-shadow: none;
@@ -481,7 +481,7 @@
.call-accept-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline: 2px solid var(--border-control);
outline-offset: 2px;
}
+37 -23
View File
@@ -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 {
@@ -92,19 +94,21 @@
overflow-y: hidden;
width: 100%;
min-height: 160px;
padding: 0;
padding: 12px 14px;
resize: none;
border: 0;
border: 1px solid var(--border-subtle);
border-radius: 14px;
outline: 0;
color: var(--text-primary);
background: transparent;
background: var(--surface);
transition: border-color 160ms ease;
font: inherit;
font-size: 16px;
line-height: 24px;
caret-color: var(--accent);
}
.channel-editor__input::placeholder { color: var(--text-secondary); }
.channel-editor__input::placeholder { color: color-mix(in srgb, var(--text-secondary) 62%, var(--background)); opacity: 1; }
.channel-editor__type {
display: grid;
@@ -113,17 +117,7 @@
font-size: 13px;
}
.channel-editor__type .select {
color: var(--text-primary);
background: var(--surface);
border-color: var(--border-control);
}
.channel-editor__input:focus-visible {
border-radius: 4px;
outline: 2px solid var(--focus-ring);
outline-offset: 4px;
}
.channel-editor__input:focus-visible { outline: 0; border-color: var(--accent); }
.channel-editor__attachments { display: grid; gap: 8px; }
.channel-editor__author { display: flex; align-items: center; gap: 12px; font-size: .875rem; }
@@ -178,16 +172,31 @@
}
.channel-editor__attach {
width: 44px;
padding: 0;
display: inline-grid;
place-items: center;
color: var(--accent);
border: 1px solid var(--border-control);
background: var(--surface);
}
.channel-editor__attach svg { width: 22px; height: 22px; stroke-width: 1.9; }
.channel-editor__submit {
color: var(--on-accent);
background: var(--accent);
}
.channel-editor__submit:disabled {
opacity: 1;
color: var(--text-secondary);
background: var(--surface-selected);
}
.channel-editor__footer.has-extra { grid-template-columns: auto auto minmax(0, 1fr) auto; }
.channel-editor__type { display: block; min-width: 0; }
.channel-editor__type-select { min-height: 44px; width: auto; max-width: 9.5rem; padding-left: 12px; border-radius: 12px; font-size: .875rem; }
.channel-editor__counter {
min-width: 0;
color: var(--text-secondary);
@@ -207,13 +216,18 @@
}
}
@media (max-width: 370px) {
.channel-editor__footer { grid-template-columns: auto minmax(0, 1fr); }
.channel-editor__counter { grid-column: 1 / -1; grid-row: 1; }
.channel-editor__attach { grid-column: 1; }
.channel-editor__submit { grid-column: 2; }
@media (max-width: 400px) {
.channel-editor__footer.has-extra { grid-template-columns: auto minmax(0, 1fr) auto; row-gap: 8px; }
.channel-editor__footer.has-extra .channel-editor__type { grid-row: 1; grid-column: 1 / 3; }
.channel-editor__footer.has-extra .channel-editor__counter { grid-row: 1; grid-column: 3; }
.channel-editor__footer.has-extra .channel-editor__attach { grid-row: 2; grid-column: 1; }
.channel-editor__footer.has-extra .channel-editor__submit { grid-row: 2; grid-column: 3; }
}
@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; }
+56
View File
@@ -0,0 +1,56 @@
/* Чипы-фильтры: ряд над списком («Все / Близкие / …»), как папки в мессенджерах.
* Выбранный чип выглядит так же, как выбранная вкладка (.tab-btn[aria-checked]). */
.chip-row {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding: var(--space-1) 0;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.chip-row::-webkit-scrollbar { display: none; }
.chip,
.fg-filter-chip {
flex-shrink: 0;
min-height: 36px;
padding: 0 14px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-pill);
color: var(--text-secondary);
background: var(--surface);
font: inherit;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: 1;
white-space: nowrap;
cursor: pointer;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.chip:hover,
.fg-filter-chip:hover { color: var(--text-primary); }
.chip[aria-pressed='true'],
.chip.is-active,
.fg-filter-chip.is-active {
color: var(--text-primary);
background: var(--surface-selected);
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-control));
font-weight: var(--weight-bold);
box-shadow: none;
}
.chip:focus-visible,
.fg-filter-chip:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.chip-row--bleed {
margin-inline: calc(-1 * var(--page-gutter));
padding-inline: var(--page-gutter);
scroll-padding-inline: var(--page-gutter);
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
+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); }
+8 -8
View File
@@ -19,7 +19,7 @@
-ms-overflow-style: none;
padding: 10px;
border-radius: 14px;
background: rgba(12, 19, 31, 0.98);
background: var(--surface);
box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.2);
}
@@ -39,7 +39,7 @@
.emoji-picker-section-title {
color: rgba(205, 218, 246, 0.78);
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
@@ -67,7 +67,7 @@
background: transparent;
background-image: none;
box-shadow: none;
color: #fff;
color: var(--text-primary);
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -107,7 +107,7 @@
.emoji-picker-tab:hover,
.emoji-picker-item:hover {
color: #fff;
color: var(--text-primary);
background: transparent;
border: 0;
box-shadow: none;
@@ -124,12 +124,12 @@
.emoji-picker-tab:active,
.emoji-picker-item:active {
color: #fff;
background: rgba(0, 0, 0, 0.12);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface) 12%, transparent);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
inset 0 -1px 2px color-mix(in srgb, var(--text-primary) 8%, transparent);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
@@ -137,7 +137,7 @@
.emoji-picker-tab:focus-visible,
.emoji-picker-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline: 2px solid var(--border-control);
outline-offset: 2px;
}
+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; }
}
+43
View File
@@ -18,3 +18,46 @@
.skeleton-line.w-90 { width: 90%; }
@keyframes skeleton-pulse { to { opacity: .4; } }
@media (prefers-reduced-motion: reduce) { .skeleton-line, .toast { animation: none; transition: none; } }
@media (max-width: 599px) {
.modal { place-items: end stretch; padding: 0; }
.modal > .modal-card {
width: 100%;
max-width: 100%;
max-height: calc(100dvh - 48px - env(safe-area-inset-top));
border-width: 1px 0 0;
border-radius: 20px 20px 0 0;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
animation: modal-sheet-in 180ms ease-out;
}
}
@keyframes modal-sheet-in { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .modal-card { animation: none; } }
/* Общий диалог подтверждения (js/components/confirm-dialog.js). */
.confirm-dialog__card { width: min(100%, 400px); display: grid; gap: 12px; }
.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; }
+36
View File
@@ -0,0 +1,36 @@
/* Меню-список: группа строк «название / подсказка / ›» — для переходов по разделам
* (кошелёк, настройки разработчика и т. п.). Для действий без перехода — обычные кнопки. */
.nav-list { display: grid; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.nav-list__title { margin: var(--space-2) 0 var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-xs); font-weight: var(--weight-bold); }
.nav-row {
width: 100%;
min-height: 56px;
padding: 10px 40px 10px 14px;
position: relative;
display: grid;
align-content: center;
gap: 2px;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0;
color: var(--text-primary);
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.nav-row:first-child { border-top: 0; }
.nav-row::after { content: '›'; position: absolute; right: 16px; top: 50%; transform: translateY(-50%); color: var(--text-secondary); font-size: 22px; line-height: 1; }
.nav-row:hover { background: var(--surface-selected); }
.nav-row:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.nav-row__label { font-size: var(--text-md); line-height: var(--leading-md); font-weight: var(--weight-medium); }
.nav-row__hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-xs); }
.nav-row--danger .nav-row__label { color: var(--danger); }
.nav-row--danger::after { content: none; }
.nav-row__label.is-empty { color: var(--text-secondary); font-weight: 400; }
.nav-list > .nav-row.profile-param-item { margin: 0; border-radius: 0; background: transparent; box-shadow: none; }
.nav-list > .nav-row.profile-param-item:hover { background: var(--surface-selected); }
.language-row::after { content: none; }
.language-row[aria-checked='true']::after { content: '✓'; color: var(--accent); font-size: 18px; font-weight: var(--weight-bold); }
+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 rgba(92, 190, 255, 0.72),
0 0 10px rgba(72, 145, 255, 0.34);
}

Some files were not shown because too many files have changed in this diff Show More