SHA256
Compare commits
4
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
2e62616b46 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
f8900e531a |
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.14.0
|
||||
client.version=1.14.1
|
||||
server.version=1.12.0
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
<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>
|
||||
${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>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">
|
||||
<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>` : ''}
|
||||
<div class="ar-attachment-manager-head">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">${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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// Сообщение уже показано в статусе.
|
||||
|
||||
@@ -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"> </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 = ' ';
|
||||
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') },
|
||||
}));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</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({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||||
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();
|
||||
}
|
||||
const channelAvatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
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);
|
||||
});
|
||||
};
|
||||
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', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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)}" />`);
|
||||
}
|
||||
|
||||
@@ -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 = '·';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-time';
|
||||
time.textContent = relative;
|
||||
primary.append(separator, time);
|
||||
}
|
||||
|
||||
text.append(primary);
|
||||
header.append(text);
|
||||
return header;
|
||||
head.append(name);
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-card__time';
|
||||
time.textContent = formatListTime(item.createdAtMs);
|
||||
head.append(time);
|
||||
return head;
|
||||
}
|
||||
|
||||
function renderEngagement(engagement) {
|
||||
if (!engagement) return null;
|
||||
|
||||
const stats = [
|
||||
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||
{ key: 'likesCount', icon: 'heart', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: 'message', label: 'Ответы' },
|
||||
{ key: 'repostsCount', icon: 'refresh', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: 'forward', label: 'Отправки' },
|
||||
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||
|
||||
if (!stats.length) return null;
|
||||
@@ -243,7 +206,7 @@ function renderEngagement(engagement) {
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'notification-engagement-icon';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconEl.textContent = icon;
|
||||
iconEl.innerHTML = iconHtml(icon);
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'notification-engagement-count';
|
||||
@@ -289,31 +252,31 @@ function bindNotificationNavigation(row, routePath, navigate) {
|
||||
|
||||
function renderItem(item, activeTab, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'card stack notification-card';
|
||||
row.className = 'notification-card';
|
||||
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||
row.append(renderIdentity(item));
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'connections') {
|
||||
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||
} else if (activeTab === 'events') {
|
||||
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
row.append(action);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'notification-card__body';
|
||||
body.append(renderHead(item));
|
||||
|
||||
if (activeTab === 'replies') {
|
||||
const body = document.createElement('p');
|
||||
body.className = 'notification-content';
|
||||
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||
row.append(body);
|
||||
|
||||
// Во вкладке «Ответы» всё – ответы: подпись «ответил(а)» не нужна, сразу текст.
|
||||
const text = document.createElement('p');
|
||||
text.className = 'notification-card__text';
|
||||
text.textContent = item.sourceText || 'Ответ без текста';
|
||||
body.append(text);
|
||||
const engagement = renderEngagement(item.engagement);
|
||||
if (engagement) row.append(engagement);
|
||||
if (engagement) body.append(engagement);
|
||||
} else {
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-card__action';
|
||||
action.textContent = activeTab === 'connections'
|
||||
? connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType)
|
||||
: eventActionLabel(item.sourceMsgSubType, item);
|
||||
body.append(action);
|
||||
}
|
||||
|
||||
row.append(renderAvatar(item), body);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -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'}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'}`;
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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, 'Не удалось показать кошелёк. Попробуйте открыть экран ещё раз.');
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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-канала автоматически.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
title: 'Настройки',
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}));
|
||||
const topbar = createTopBar({
|
||||
title: 'Настройки',
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const logoButton = document.createElement('button');
|
||||
logoButton.type = 'button';
|
||||
logoButton.className = 'settings-shine-logo-button';
|
||||
logoButton.setAttribute('aria-label', 'Логотип 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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '–';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
@@ -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
Reference in New Issue
Block a user