Compare commits
23 Commits
a788d8bcf5
...
d0e7998650
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
d0e7998650 | ||
|
|
fec5e49304 | ||
|
|
3b12e14e71 | ||
|
|
86eaf2139d | ||
|
|
65fad993ad | ||
|
|
55e6e477be | ||
| ba5efcc152 | |||
| 26253564d5 | |||
| 92791c77a9 | |||
| 465792b2ab | |||
| de269fd828 | |||
| 8c91484f37 | |||
| 6904ac8b7c | |||
| aea6bbcb0e | |||
| 7ad74942e0 | |||
| ac1cc04637 | |||
| b4480d89cf | |||
| ff584ba5d1 | |||
| 69f0fdf120 | |||
| e3bebff618 | |||
| f19f7b0ec4 | |||
| 0b4374141e | |||
| 652ddc9d88 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -104,3 +104,9 @@ ESP32/**/*.a
|
|||||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||||
server-backup/archive/**
|
server-backup/archive/**
|
||||||
!server-backup/archive/.gitkeep
|
!server-backup/archive/.gitkeep
|
||||||
|
|
||||||
|
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
|
||||||
|
.claude/
|
||||||
|
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
||||||
|
*.bak.png
|
||||||
|
shine-UI/assets/navbar_preview.png
|
||||||
|
|||||||
@ -45,4 +45,4 @@
|
|||||||
|
|
||||||
### Дальнее будущее
|
### Дальнее будущее
|
||||||
|
|
||||||
- Сейчас задач нет.
|
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
|
||||||
|
|
||||||
|
## Зачем нужна фича
|
||||||
|
|
||||||
|
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
|
||||||
|
|
||||||
|
Цели:
|
||||||
|
- отправлять на homeserver технические команды в текстовом виде;
|
||||||
|
- получать текстовый ответ на команду;
|
||||||
|
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
|
||||||
|
- хранить полученные файлы на SD-карте homeserver;
|
||||||
|
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
|
||||||
|
|
||||||
|
## Горизонт
|
||||||
|
|
||||||
|
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
|
||||||
|
|
||||||
|
## Что именно имеется в виду
|
||||||
|
|
||||||
|
### 1. Единая модель технической команды
|
||||||
|
|
||||||
|
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
|
||||||
|
- через любой доступный сервер SHiNE;
|
||||||
|
- через уже установленный WebRTC DataChannel.
|
||||||
|
|
||||||
|
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
|
||||||
|
|
||||||
|
### 2. Команда как короткоживущий подписанный сигнал
|
||||||
|
|
||||||
|
У команды должны быть:
|
||||||
|
- `commandId`;
|
||||||
|
- временная метка;
|
||||||
|
- TTL около 10 секунд;
|
||||||
|
- криптографическая подпись.
|
||||||
|
|
||||||
|
Смысл такой:
|
||||||
|
- если команда быстро дошла, homeserver подтверждает принятие;
|
||||||
|
- если не дошла вовремя, команда считается протухшей;
|
||||||
|
- отправитель может безопасно послать повтор;
|
||||||
|
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
|
||||||
|
|
||||||
|
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
|
||||||
|
|
||||||
|
### 3. Текстовые технические команды
|
||||||
|
|
||||||
|
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
|
||||||
|
- отправил строку-команду;
|
||||||
|
- получил строку-ответ.
|
||||||
|
|
||||||
|
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
|
||||||
|
|
||||||
|
### 4. Передача файлов только при наличии DataChannel
|
||||||
|
|
||||||
|
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
|
||||||
|
|
||||||
|
Предварительная модель:
|
||||||
|
- имя файла = `SHA-256` содержимого;
|
||||||
|
- можно запросить диапазон байт `from..to`;
|
||||||
|
- можно отправить диапазон байт `from..to`;
|
||||||
|
- homeserver хранит полученные данные на SD-карте;
|
||||||
|
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
|
||||||
|
|
||||||
|
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
|
||||||
|
|
||||||
|
### 5. Установка data-соединения по явной команде
|
||||||
|
|
||||||
|
Нужна техническая команда уровня:
|
||||||
|
- `установить data-соединение`.
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
|
||||||
|
- либо `нет` и причина отказа.
|
||||||
|
|
||||||
|
### 6. Доставка на пользовательские сессии
|
||||||
|
|
||||||
|
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
|
||||||
|
|
||||||
|
Идея:
|
||||||
|
- на любую активную сессию пользователя можно посылать техническую команду;
|
||||||
|
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
|
||||||
|
|
||||||
|
## Что нужно будет сделать при возврате к задаче
|
||||||
|
|
||||||
|
- Спроектировать отдельный формат технических команд и ack-ответов.
|
||||||
|
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
|
||||||
|
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
|
||||||
|
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
|
||||||
|
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
|
||||||
|
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
|
||||||
|
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
|
||||||
|
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
|
||||||
|
|
||||||
|
## Вопросы для будущего уточнения
|
||||||
|
|
||||||
|
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
|
||||||
|
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
|
||||||
|
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
|
||||||
|
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
|
||||||
|
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
|
||||||
|
|
||||||
|
## Что уже сделано
|
||||||
|
|
||||||
|
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
|
||||||
|
|
||||||
|
## Какие документы нужно будет обновить при реализации
|
||||||
|
|
||||||
|
- `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
|
||||||
|
- `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции.
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
|
||||||
|
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
|
||||||
|
|
||||||
|
## С какого места продолжать позже
|
||||||
|
|
||||||
|
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
|
||||||
@ -1,5 +1,7 @@
|
|||||||
# Дальнее будущее
|
# Дальнее будущее
|
||||||
|
|
||||||
Сейчас в этом горизонте нет активных идей.
|
|
||||||
|
|
||||||
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
||||||
|
|
||||||
|
## Идеи
|
||||||
|
|
||||||
|
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Регистрация: FAQ и режим пароля из 12 слов
|
||||||
|
|
||||||
|
- краткое описание:
|
||||||
|
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
|
||||||
|
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
|
||||||
|
- такой же режим добавлен и на экран входа по логину и паролю.
|
||||||
|
|
||||||
|
- что проверять:
|
||||||
|
- на стартовом экране открыть `Зарегистрироваться`;
|
||||||
|
- убедиться, что внизу экрана есть кнопки FAQ;
|
||||||
|
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
|
||||||
|
- включить галочку `Представить пароль в виде 12 слов`;
|
||||||
|
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
|
||||||
|
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
|
||||||
|
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
|
||||||
|
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
|
||||||
|
- пройти регистрацию до шага оплаты без ошибок интерфейса.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- FAQ открывается отдельным экраном и содержит понятные ответы;
|
||||||
|
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
|
||||||
|
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.219
|
client.version=1.2.226
|
||||||
server.version=1.2.207
|
server.version=1.2.212
|
||||||
|
|||||||
BIN
shine-UI/assets/glass_overlay_faithful.png
Normal file
BIN
shine-UI/assets/glass_overlay_faithful.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
BIN
shine-UI/assets/icon_kanaly.png
Normal file
BIN
shine-UI/assets/icon_kanaly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
shine-UI/assets/icon_lichnye.png
Normal file
BIN
shine-UI/assets/icon_lichnye.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
BIN
shine-UI/assets/icon_profil.png
Normal file
BIN
shine-UI/assets/icon_profil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
BIN
shine-UI/assets/icon_svyazi.png
Normal file
BIN
shine-UI/assets/icon_svyazi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
shine-UI/assets/icon_uvedomleniya.png
Normal file
BIN
shine-UI/assets/icon_uvedomleniya.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
74
shine-UI/docs/design/messages-list-v2.md
Normal file
74
shine-UI/docs/design/messages-list-v2.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Личные сообщения (messages-list) — дизайн v2
|
||||||
|
|
||||||
|
Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет
|
||||||
|
обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header.
|
||||||
|
|
||||||
|
> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository.
|
||||||
|
|
||||||
|
## Источник данных
|
||||||
|
- **Demo/lab:** мок `js/mock-data.js` → `directMessages` (семантические поля, цвет не хранится).
|
||||||
|
- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`).
|
||||||
|
- **Маршруты:**
|
||||||
|
- `/messages-list` — защищённый (требует сессии).
|
||||||
|
- `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов).
|
||||||
|
|
||||||
|
## Семантика → визуал
|
||||||
|
Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится.
|
||||||
|
|
||||||
|
Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`,
|
||||||
|
`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.)
|
||||||
|
|
||||||
|
### Цвета (значение)
|
||||||
|
| Цвет | Токен | Значение |
|
||||||
|
|------|-------|----------|
|
||||||
|
| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) |
|
||||||
|
| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь |
|
||||||
|
| celestial | `--rel-shining` `#68D8FF` | сияющий |
|
||||||
|
| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» |
|
||||||
|
|
||||||
|
Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`.
|
||||||
|
**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус).
|
||||||
|
|
||||||
|
### Приоритет правого статуса
|
||||||
|
`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего.
|
||||||
|
На карточке максимум ОДИН главный статус.
|
||||||
|
|
||||||
|
### Unread
|
||||||
|
Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `1–99`, далее `99+`.
|
||||||
|
Идёт после статуса, перед chevron.
|
||||||
|
|
||||||
|
## Матрица состояний (demo-мок покрывает все)
|
||||||
|
| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| M01 | contact | – | ✓ | – | 0 | violet | 🛡 Подтверждён | – |
|
||||||
|
| M02 | contact | – | – | ✓ | 2 | violet | 🔗 Связь | 2 |
|
||||||
|
| M03 | contact | ✓ | – | ✓ | 5 | celestial | 🔗 Связь | 5 |
|
||||||
|
| M04 | contact | – | – | – | 0 | violet | — | – |
|
||||||
|
| M05 | family | – | ✓ | – | 0 | gold | 🛡 Подтверждён | – |
|
||||||
|
| M06 | family | – | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 |
|
||||||
|
|
||||||
|
## Размеры
|
||||||
|
- Карточка: `min-height 92px`, `radius 26px`.
|
||||||
|
- Зазор списка: `8px` (flex-column).
|
||||||
|
- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри).
|
||||||
|
- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill.
|
||||||
|
- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`.
|
||||||
|
|
||||||
|
## Сияющая сфера — связь с «Связями» (обязательно)
|
||||||
|
DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**:
|
||||||
|
- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow)
|
||||||
|
+ `fg-shine-halo` (дыхание ореола: scale/opacity);
|
||||||
|
- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`;
|
||||||
|
- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа
|
||||||
|
(узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен);
|
||||||
|
- `filter: blur(3.4px)` ≡ `feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow`
|
||||||
|
графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»;
|
||||||
|
- `prefers-reduced-motion` → анимации выключаются.
|
||||||
|
|
||||||
|
Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров,
|
||||||
|
если они уже существуют в визуальном языке «Связей».
|
||||||
|
|
||||||
|
## Фон
|
||||||
|
Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**.
|
||||||
|
Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»).
|
||||||
|
Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним.
|
||||||
@ -35,6 +35,7 @@ import {
|
|||||||
import * as startView from './pages/start-view.js?v=202606142105';
|
import * as startView from './pages/start-view.js?v=202606142105';
|
||||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||||
import * as registerView from './pages/register-view.js';
|
import * as registerView from './pages/register-view.js';
|
||||||
|
import * as registrationFaqView from './pages/registration-faq-view.js';
|
||||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||||
@ -81,6 +82,7 @@ const routes = {
|
|||||||
'start-view': startView,
|
'start-view': startView,
|
||||||
'entry-settings-view': entrySettingsView,
|
'entry-settings-view': entrySettingsView,
|
||||||
'register-view': registerView,
|
'register-view': registerView,
|
||||||
|
'registration-faq-view': registrationFaqView,
|
||||||
'registration-payment-view': registrationPaymentView,
|
'registration-payment-view': registrationPaymentView,
|
||||||
'registration-keys-view': registrationKeysView,
|
'registration-keys-view': registrationKeysView,
|
||||||
'registration-draft-keys-view': registrationDraftKeysView,
|
'registration-draft-keys-view': registrationDraftKeysView,
|
||||||
|
|||||||
@ -2,14 +2,23 @@ import { resolveToolbarActive } from '../router.js';
|
|||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
|
|
||||||
|
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
||||||
|
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
||||||
|
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю).
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ pageId: 'messages-list', label: 'личные', icon: '💬' },
|
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
|
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
|
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function iconHtml(item) {
|
||||||
|
return item.iconImg
|
||||||
|
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />`
|
||||||
|
: `<span>${item.icon}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function getTotalUnreadMessages() {
|
function getTotalUnreadMessages() {
|
||||||
const chats = Object.values(state.chats || {});
|
const chats = Object.values(state.chats || {});
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@ -62,10 +71,10 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const isProfile = item.pageId === 'profile-view';
|
const isProfile = item.pageId === 'profile-view';
|
||||||
const isMessages = item.pageId === 'messages-list';
|
const isMessages = item.pageId === 'messages-list';
|
||||||
const isNetwork = item.pageId === 'network-view';
|
const isNetwork = item.pageId === 'network-view';
|
||||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}`;
|
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) {
|
if (isProfile) {
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
<span>${item.icon}</span>
|
${iconHtml(item)}
|
||||||
<span class="toolbar-label-wrap">
|
<span class="toolbar-label-wrap">
|
||||||
<span>${item.label}</span>
|
<span>${item.label}</span>
|
||||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||||
@ -75,7 +84,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`;
|
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||||
}
|
}
|
||||||
if (isMessages && unreadTotal > 0) {
|
if (isMessages && unreadTotal > 0) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
|
|||||||
@ -39,39 +39,25 @@ export const deviceSessions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Экран «Личные сообщения» — списочная форма «Связей». Храним СЕМАНТИКУ, не цвет.
|
||||||
|
// Цвет/режим вычисляет js/pages/messages/dm-visual-resolver.js (resolveDmVisualState):
|
||||||
|
// relationType: 'contact' | 'friend' | 'family' (family → золотой обод)
|
||||||
|
// relationRole: 'parent'|'child'|'sibling'|'spouse'|null
|
||||||
|
// isShining: true → небесный (celestial) обод/свечение (важнее relationType)
|
||||||
|
// isConfirmed: true → статус доверия «Подтверждён» (золотой shield) — НЕ красит обод
|
||||||
|
// hasActiveLink: true → статус «Связь» (изумруд) — приоритетнее «Подтверждён»
|
||||||
|
// unreadCount: number; preview: string
|
||||||
|
// toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать
|
||||||
|
// (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо)
|
||||||
|
// ЛС — мок-плейсхолдер (на проде заменяется реальными relations/chats). Поля СЕМАНТИЧЕСКИЕ
|
||||||
|
// (без хранения цвета) — визуал решает dm-visual-resolver.js. Аватары — через профиль (инициалы, пока нет фото).
|
||||||
export const directMessages = [
|
export const directMessages = [
|
||||||
{
|
{ id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
|
||||||
id: 'u1',
|
{ id: 'u2', login: 'ilya', name: 'Илья П.', initials: 'ИП', preview: 'Спасибо, уже проверяю!', lastMessage: 'Спасибо, уже проверяю!', time: '14:31', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: true, unreadCount: 2, connectedVia: [{ login: 'pavel', name: 'Павел С.' }] },
|
||||||
name: 'Марина К.',
|
{ id: 'u3', login: 'elena', name: 'Елена Д.', initials: 'ЕД', preview: 'Тестовый стенд снова доступен.', lastMessage: 'Тестовый стенд снова доступен.', time: '13:02', relationType: 'contact', relationRole: null, isShining: true, isConfirmed: false, hasActiveLink: true, unreadCount: 5, connectedVia: [{ login: 'pavel', name: 'Павел С.' }, { login: 'marina', name: 'Марина К.' }] },
|
||||||
initials: 'МК',
|
{ id: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0 },
|
||||||
lastMessage: 'Вечером скину обновления по макетам.',
|
{ id: 'u6', login: 'pavel', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
|
||||||
time: '15:08',
|
{ id: 'u7', login: 'anya', name: 'Аня В.', initials: 'АВ', preview: 'Семейный чат: жду в 19:00.', lastMessage: 'Семейный чат: жду в 19:00.', time: 'пн', relationType: 'family', relationRole: 'sibling', isShining: false, isConfirmed: true, hasActiveLink: true, unreadCount: 1, connectedVia: [{ login: 'marina', name: 'Марина К.' }] },
|
||||||
unread: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'u2',
|
|
||||||
name: 'Илья П.',
|
|
||||||
initials: 'ИП',
|
|
||||||
lastMessage: 'Спасибо, уже проверяю!',
|
|
||||||
time: '14:31',
|
|
||||||
unread: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'u3',
|
|
||||||
name: 'Елена Д.',
|
|
||||||
initials: 'ЕД',
|
|
||||||
lastMessage: 'Тестовый стенд снова доступен.',
|
|
||||||
time: '13:02',
|
|
||||||
unread: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'u4',
|
|
||||||
name: 'Никита О.',
|
|
||||||
initials: 'НО',
|
|
||||||
lastMessage: 'Отлично, давай так и сделаем.',
|
|
||||||
time: 'вчера',
|
|
||||||
unread: 0,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const contactDirectory = [
|
export const contactDirectory = [
|
||||||
@ -271,136 +257,3 @@ export const notifications = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const networkGraph = {
|
|
||||||
center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 },
|
|
||||||
peers: [
|
|
||||||
{ id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 },
|
|
||||||
{ id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 },
|
|
||||||
{ id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 },
|
|
||||||
{ id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Мок интерактивной карты связей в форме ТЗ (focusUser + connections[]).
|
|
||||||
// Используется лабораторным режимом `network-view/lab` для проверки физики/центрирования.
|
|
||||||
// relationType: family | friend | business | contact; connectionStrength: 0..1 (сильнее → ближе к центру);
|
|
||||||
// status: 'shining' даёт эффект свечения; hasOwnConnections — есть ли у узла свои связи (для глубины).
|
|
||||||
export const networkGraphMock = {
|
|
||||||
focusUser: { id: 'u_100', login: 'ivan', name: 'Иван', avatar: 'url_to_image', status: 'shining' },
|
|
||||||
connections: [
|
|
||||||
{ id: 'u_101', login: 'alisa', name: 'Алиса', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.95, hasOwnConnections: true, status: 'shining' },
|
|
||||||
{ id: 'u_102', login: 'pavel', name: 'Павел', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.45, hasOwnConnections: false },
|
|
||||||
{ id: 'u_103', login: 'marina', name: 'Марина', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.8, hasOwnConnections: true },
|
|
||||||
{ id: 'u_104', login: 'ilya', name: 'Илья', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.6, hasOwnConnections: true },
|
|
||||||
{ id: 'u_105', login: 'elena', name: 'Елена', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.88, hasOwnConnections: false },
|
|
||||||
{ id: 'u_106', login: 'nikita', name: 'Никита', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.3, hasOwnConnections: false },
|
|
||||||
{ id: 'u_107', login: 'oleg', name: 'Олег', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.55, hasOwnConnections: true, status: 'shining' },
|
|
||||||
{ id: 'u_108', login: 'sveta', name: 'Света', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.7, hasOwnConnections: false },
|
|
||||||
{ id: 'u_109', login: 'dmitry', name: 'Дмитрий', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.4, hasOwnConnections: true },
|
|
||||||
{ id: 'u_110', login: 'anna', name: 'Анна', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.92, hasOwnConnections: false },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Связанный мульти-пользовательский граф для лаборатории (network-view/lab):
|
|
||||||
// у каждого пользователя свой набор связей, тап по узлу переключает карту на его сеть.
|
|
||||||
// Сияющими считаем ivan/alisa/oleg — у них статус подсвечивается и в их карточках у других.
|
|
||||||
const NETWORK_NAMES = {
|
|
||||||
ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий',
|
|
||||||
oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл',
|
|
||||||
};
|
|
||||||
const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg', 'marina', 'nina']);
|
|
||||||
// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории.
|
|
||||||
// Если сети нет — узлы мягко падают на инициалы (img.onerror).
|
|
||||||
const NETWORK_PHOTOS = {
|
|
||||||
ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5',
|
|
||||||
pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9',
|
|
||||||
dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52',
|
|
||||||
nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44',
|
|
||||||
sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60',
|
|
||||||
};
|
|
||||||
|
|
||||||
function networkConn(login, relationType, connectionStrength) {
|
|
||||||
return {
|
|
||||||
id: login,
|
|
||||||
login,
|
|
||||||
name: NETWORK_NAMES[login] || login,
|
|
||||||
avatar: null,
|
|
||||||
photo: NETWORK_PHOTOS[login] || null,
|
|
||||||
relationType,
|
|
||||||
connectionStrength,
|
|
||||||
hasOwnConnections: true,
|
|
||||||
status: NETWORK_SHINING.has(login) ? 'shining' : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function networkPerson(login, connections) {
|
|
||||||
return {
|
|
||||||
focusUser: {
|
|
||||||
id: login,
|
|
||||||
login,
|
|
||||||
name: NETWORK_NAMES[login] || login,
|
|
||||||
avatar: null,
|
|
||||||
photo: NETWORK_PHOTOS[login] || null,
|
|
||||||
status: NETWORK_SHINING.has(login) ? 'shining' : '',
|
|
||||||
},
|
|
||||||
connections,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const networkGraphUsers = {
|
|
||||||
ivan: networkPerson('ivan', [
|
|
||||||
networkConn('alisa', 'friend', 0.9),
|
|
||||||
networkConn('pavel', 'friend', 0.7),
|
|
||||||
networkConn('elena', 'family', 0.95),
|
|
||||||
networkConn('dmitry', 'family', 0.95),
|
|
||||||
networkConn('oleg', 'business', 0.5),
|
|
||||||
networkConn('nina', 'contact', 0.35),
|
|
||||||
networkConn('kirill', 'friend', 0.6),
|
|
||||||
]),
|
|
||||||
alisa: networkPerson('alisa', [
|
|
||||||
networkConn('ivan', 'friend', 0.9),
|
|
||||||
networkConn('marina', 'friend', 0.8),
|
|
||||||
networkConn('sveta', 'contact', 0.4),
|
|
||||||
networkConn('elena', 'contact', 0.3),
|
|
||||||
]),
|
|
||||||
pavel: networkPerson('pavel', [
|
|
||||||
networkConn('ivan', 'friend', 0.7),
|
|
||||||
networkConn('oleg', 'business', 0.6),
|
|
||||||
networkConn('kirill', 'friend', 0.5),
|
|
||||||
]),
|
|
||||||
elena: networkPerson('elena', [
|
|
||||||
networkConn('ivan', 'family', 0.95),
|
|
||||||
networkConn('dmitry', 'family', 0.9),
|
|
||||||
networkConn('alisa', 'contact', 0.3),
|
|
||||||
]),
|
|
||||||
dmitry: networkPerson('dmitry', [
|
|
||||||
networkConn('ivan', 'family', 0.95),
|
|
||||||
networkConn('elena', 'family', 0.9),
|
|
||||||
networkConn('pavel', 'business', 0.4),
|
|
||||||
]),
|
|
||||||
oleg: networkPerson('oleg', [
|
|
||||||
networkConn('pavel', 'business', 0.6),
|
|
||||||
networkConn('ivan', 'business', 0.5),
|
|
||||||
networkConn('nina', 'contact', 0.45),
|
|
||||||
]),
|
|
||||||
nina: networkPerson('nina', [
|
|
||||||
networkConn('ivan', 'contact', 0.35),
|
|
||||||
networkConn('oleg', 'contact', 0.45),
|
|
||||||
networkConn('sveta', 'friend', 0.5),
|
|
||||||
]),
|
|
||||||
marina: networkPerson('marina', [
|
|
||||||
networkConn('alisa', 'friend', 0.8),
|
|
||||||
networkConn('sveta', 'friend', 0.7),
|
|
||||||
networkConn('kirill', 'contact', 0.4),
|
|
||||||
]),
|
|
||||||
sveta: networkPerson('sveta', [
|
|
||||||
networkConn('marina', 'friend', 0.7),
|
|
||||||
networkConn('alisa', 'contact', 0.4),
|
|
||||||
networkConn('nina', 'friend', 0.5),
|
|
||||||
]),
|
|
||||||
kirill: networkPerson('kirill', [
|
|
||||||
networkConn('ivan', 'friend', 0.6),
|
|
||||||
networkConn('pavel', 'friend', 0.5),
|
|
||||||
networkConn('marina', 'contact', 0.4),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|||||||
@ -71,7 +71,7 @@ function openMessageActionsMenu({
|
|||||||
const menuId = `chat-message-actions-menu-${Date.now()}`;
|
const menuId = `chat-message-actions-menu-${Date.now()}`;
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||||
<div class="modal-card stack dm-dialog-card dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
||||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||||
@ -579,6 +579,10 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||||
|
scrollToLatestMessage(log);
|
||||||
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
|
window.setTimeout(() => scrollToLatestMessage(log), 90);
|
||||||
|
window.setTimeout(() => scrollToLatestMessage(log), 220);
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'outgoing-dm',
|
source: 'outgoing-dm',
|
||||||
|
|||||||
@ -61,9 +61,11 @@ function createSearchAvatar(login) {
|
|||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-search-screen';
|
screen.className = 'stack dm-screen dm-search-screen';
|
||||||
|
let searchTimer = 0;
|
||||||
|
let searchSeq = 0;
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'input dm-input';
|
input.className = 'input dm-input contact-search-input';
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.name = 'contact';
|
input.name = 'contact';
|
||||||
input.placeholder = 'Введите начало логина';
|
input.placeholder = 'Введите начало логина';
|
||||||
@ -71,26 +73,28 @@ export function render({ navigate }) {
|
|||||||
input.maxLength = 80;
|
input.maxLength = 80;
|
||||||
|
|
||||||
const resultsCard = document.createElement('section');
|
const resultsCard = document.createElement('section');
|
||||||
resultsCard.className = 'card stack dm-dialog-card';
|
resultsCard.className = 'card stack contact-search-results-card';
|
||||||
resultsCard.hidden = true;
|
resultsCard.hidden = true;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'meta-muted';
|
status.className = 'contact-search-results-title';
|
||||||
|
|
||||||
const resultsList = document.createElement('div');
|
const resultsList = document.createElement('div');
|
||||||
resultsList.className = 'stack dm-list';
|
resultsList.className = 'stack dm-list';
|
||||||
|
|
||||||
const renderResults = (matches, query) => {
|
const renderResults = (matches, query) => {
|
||||||
resultsList.innerHTML = '';
|
resultsList.innerHTML = '';
|
||||||
resultsCard.hidden = false;
|
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
status.textContent = 'Введите начало логина пользователя.';
|
status.textContent = '';
|
||||||
|
resultsCard.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsCard.hidden = false;
|
||||||
|
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
status.textContent = 'Совпадений не найдено.';
|
status.textContent = 'Найдено пользователей: 0';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,11 +105,10 @@ export function render({ navigate }) {
|
|||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
const avatarEl = createSearchAvatar(login);
|
const avatarEl = createSearchAvatar(login);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div>
|
<div class="contact-search-result-main">
|
||||||
<strong>${login}</strong>
|
<strong class="dm-row-title">${login}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted">Профиль</div>
|
<span class="dm-chevron" aria-hidden="true">›</span>
|
||||||
`;
|
`;
|
||||||
row.prepend(avatarEl);
|
row.prepend(avatarEl);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@ -115,12 +118,9 @@ export function render({ navigate }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchButton = document.createElement('button');
|
const runSearch = async () => {
|
||||||
searchButton.className = 'primary-btn dm-send-btn';
|
|
||||||
searchButton.type = 'button';
|
|
||||||
searchButton.textContent = 'Поиск';
|
|
||||||
searchButton.addEventListener('click', async () => {
|
|
||||||
const query = input.value.trim();
|
const query = input.value.trim();
|
||||||
|
const seq = ++searchSeq;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
renderResults([], '');
|
renderResults([], '');
|
||||||
return;
|
return;
|
||||||
@ -128,11 +128,38 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const logins = await authService.searchUsers(query);
|
const logins = await authService.searchUsers(query);
|
||||||
|
if (seq !== searchSeq) return;
|
||||||
renderResults((logins || []).slice(0, 5), query);
|
renderResults((logins || []).slice(0, 5), query);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== searchSeq) return;
|
||||||
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
|
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
|
||||||
resultsCard.hidden = false;
|
resultsCard.hidden = false;
|
||||||
|
resultsList.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSearch = () => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer);
|
||||||
|
searchTimer = window.setTimeout(() => {
|
||||||
|
searchTimer = 0;
|
||||||
|
void runSearch();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchButton = document.createElement('button');
|
||||||
|
searchButton.className = 'primary-btn dm-send-btn';
|
||||||
|
searchButton.type = 'button';
|
||||||
|
searchButton.textContent = 'Поиск';
|
||||||
|
searchButton.addEventListener('click', async () => {
|
||||||
|
if (searchTimer) {
|
||||||
|
window.clearTimeout(searchTimer);
|
||||||
|
searchTimer = 0;
|
||||||
|
}
|
||||||
|
await runSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
scheduleSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
@ -140,7 +167,7 @@ export function render({ navigate }) {
|
|||||||
controls.append(searchButton);
|
controls.append(searchButton);
|
||||||
|
|
||||||
const formCard = document.createElement('section');
|
const formCard = document.createElement('section');
|
||||||
formCard.className = 'card stack dm-dialog-card';
|
formCard.className = 'card stack contact-search-form-card';
|
||||||
formCard.append(input, controls);
|
formCard.append(input, controls);
|
||||||
|
|
||||||
resultsCard.append(status, resultsList);
|
resultsCard.append(status, resultsList);
|
||||||
@ -154,5 +181,9 @@ export function render({ navigate }) {
|
|||||||
resultsCard,
|
resultsCard,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
screen.cleanup = () => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer);
|
||||||
|
};
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack auth-screen auth-screen--lower';
|
||||||
let pollTimer = 0;
|
let pollTimer = 0;
|
||||||
let countdownTimer = 0;
|
let countdownTimer = 0;
|
||||||
let activePairingId = '';
|
let activePairingId = '';
|
||||||
@ -77,6 +77,10 @@ export function render({ navigate }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const panel = document.createElement('section');
|
||||||
|
panel.className = 'login-panel login-panel--wide stack';
|
||||||
|
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
|
||||||
|
|
||||||
const formCard = document.createElement('div');
|
const formCard = document.createElement('div');
|
||||||
formCard.className = 'card stack';
|
formCard.className = 'card stack';
|
||||||
formCard.innerHTML = `
|
formCard.innerHTML = `
|
||||||
@ -387,6 +391,7 @@ export function render({ navigate }) {
|
|||||||
resultActions.append(cancelBtn);
|
resultActions.append(cancelBtn);
|
||||||
resultWrap.append(resultActions);
|
resultWrap.append(resultActions);
|
||||||
|
|
||||||
screen.append(formCard, status, resultWrap);
|
panel.append(formCard, status, resultWrap);
|
||||||
|
screen.append(panel);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,55 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
import {
|
||||||
|
composePasswordFromWords,
|
||||||
|
emptyPasswordWords,
|
||||||
|
normalizePasswordWords,
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_WORDS_COUNT,
|
||||||
|
} from '../services/password-words.js';
|
||||||
|
|
||||||
|
function createWordsLayout({ words, onInput }) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'registration-words-block';
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'registration-words-grid';
|
||||||
|
|
||||||
|
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'registration-word-row';
|
||||||
|
|
||||||
|
const number = document.createElement('span');
|
||||||
|
number.className = 'registration-word-number';
|
||||||
|
number.textContent = `${index + 1}.`;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'input registration-word-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.autocapitalize = 'off';
|
||||||
|
input.spellcheck = false;
|
||||||
|
input.maxLength = 32;
|
||||||
|
input.value = words[index];
|
||||||
|
input.addEventListener('input', () => onInput(index, input.value));
|
||||||
|
|
||||||
|
row.append(number, input);
|
||||||
|
grid.append(row);
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.className = 'meta-muted';
|
||||||
|
hint.textContent =
|
||||||
|
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
|
||||||
|
|
||||||
|
const preview = document.createElement('p');
|
||||||
|
preview.className = 'status-line';
|
||||||
|
|
||||||
|
section.append(grid, hint, preview);
|
||||||
|
return { section, inputs, preview };
|
||||||
|
}
|
||||||
|
|
||||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||||
|
|
||||||
@ -19,6 +68,9 @@ export function render({ navigate }) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
|
|
||||||
|
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||||
|
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||||
|
|
||||||
const loginInput = document.createElement('input');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
@ -35,21 +87,47 @@ export function render({ navigate }) {
|
|||||||
passwordInput.autocomplete = 'new-password';
|
passwordInput.autocomplete = 'new-password';
|
||||||
passwordInput.autocapitalize = 'off';
|
passwordInput.autocapitalize = 'off';
|
||||||
passwordInput.spellcheck = false;
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.loginDraft.password;
|
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
|
||||||
|
passwordInput.placeholder = 'Введите пароль';
|
||||||
|
|
||||||
|
const {
|
||||||
|
section: wordsSection,
|
||||||
|
inputs: wordInputs,
|
||||||
|
preview: wordsPreview,
|
||||||
|
} = createWordsLayout({
|
||||||
|
words: passwordWords,
|
||||||
|
onInput: (index, value) => {
|
||||||
|
passwordWords[index] = value;
|
||||||
|
syncDraftState();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordModeToggle = document.createElement('label');
|
||||||
|
passwordModeToggle.className = 'registration-toggle';
|
||||||
|
|
||||||
|
const passwordModeCheckbox = document.createElement('input');
|
||||||
|
passwordModeCheckbox.type = 'checkbox';
|
||||||
|
passwordModeCheckbox.checked = passwordMode === 'words';
|
||||||
|
|
||||||
|
const passwordModeLabel = document.createElement('span');
|
||||||
|
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
|
||||||
|
|
||||||
|
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
|
||||||
|
|
||||||
const hint = document.createElement('p');
|
const hint = document.createElement('p');
|
||||||
hint.className = 'meta-muted';
|
hint.className = 'meta-muted';
|
||||||
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
|
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||||
|
|
||||||
const advanced = document.createElement('details');
|
const advanced = document.createElement('details');
|
||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<summary>Расширенные</summary>
|
||||||
<p class="meta-muted">Схема derivation ключей: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</p>
|
<p class="meta-muted">Схема деривации: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет.</p>
|
||||||
|
<p class="meta-muted">Из секрета строятся root key, blockchain key и device key. Обычно можно не вникать в это подробно и просто хранить всё на своём устройстве.</p>
|
||||||
|
<p class="meta-muted">Режим 12 слов ничего не меняет в протоколе: слова просто склеиваются в один обычный пароль длиной до 256 символов.</p>
|
||||||
<p class="meta-muted">Если пароль пустой — используется прежний детерминированный режим совместимости.</p>
|
<p class="meta-muted">Если пароль пустой — используется прежний детерминированный режим совместимости.</p>
|
||||||
<p class="meta-muted">Для тестов можно оставить пустой пароль.</p>
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32.</p>
|
||||||
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
@ -60,13 +138,59 @@ export function render({ navigate }) {
|
|||||||
testLoginsHint.className = 'meta-muted';
|
testLoginsHint.className = 'meta-muted';
|
||||||
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
||||||
|
|
||||||
|
function getCurrentPassword() {
|
||||||
|
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDraftState() {
|
||||||
|
state.loginDraft.login = loginInput.value.trim();
|
||||||
|
state.loginDraft.passwordMode = passwordMode;
|
||||||
|
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||||
|
state.loginDraft.password = getCurrentPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWordsPreview() {
|
||||||
|
const password = composePasswordFromWords(passwordWords);
|
||||||
|
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||||
|
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePasswordModeVisibility() {
|
||||||
|
const wordsMode = passwordMode === 'words';
|
||||||
|
wordsSection.hidden = !wordsMode;
|
||||||
|
passwordInput.parentElement.hidden = wordsMode;
|
||||||
|
updateWordsPreview();
|
||||||
|
}
|
||||||
|
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
<label class="stack"><span class="field-label">Пароль</span></label>
|
||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(hint, advanced, status, testLoginsHint);
|
form.append(passwordModeToggle, wordsSection, hint, advanced, status, testLoginsHint);
|
||||||
|
updatePasswordModeVisibility();
|
||||||
|
syncDraftState();
|
||||||
|
|
||||||
|
loginInput.addEventListener('input', syncDraftState);
|
||||||
|
passwordInput.addEventListener('input', syncDraftState);
|
||||||
|
|
||||||
|
passwordModeCheckbox.addEventListener('change', () => {
|
||||||
|
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
|
||||||
|
if (nextMode === passwordMode) return;
|
||||||
|
if (nextMode === 'words') {
|
||||||
|
passwordWords = emptyPasswordWords();
|
||||||
|
wordInputs.forEach((input) => {
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
passwordInput.value = '';
|
||||||
|
} else {
|
||||||
|
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||||
|
}
|
||||||
|
passwordMode = nextMode;
|
||||||
|
updatePasswordModeVisibility();
|
||||||
|
syncDraftState();
|
||||||
|
});
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -83,14 +207,18 @@ export function render({ navigate }) {
|
|||||||
enterButton.textContent = 'Войти';
|
enterButton.textContent = 'Войти';
|
||||||
enterButton.addEventListener('click', async () => {
|
enterButton.addEventListener('click', async () => {
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
state.loginDraft.login = loginInput.value.trim();
|
syncDraftState();
|
||||||
state.loginDraft.password = passwordInput.value;
|
|
||||||
|
|
||||||
if (!state.loginDraft.login) {
|
if (!state.loginDraft.login) {
|
||||||
status.textContent = 'Введите логин.';
|
status.textContent = 'Введите логин.';
|
||||||
status.style.display = '';
|
status.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
||||||
|
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||||
|
status.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAuthBusy(true);
|
setAuthBusy(true);
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
@ -103,6 +231,8 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.flowType = 'login';
|
state.registrationDraft.flowType = 'login';
|
||||||
state.registrationDraft.login = result.login;
|
state.registrationDraft.login = result.login;
|
||||||
state.registrationDraft.password = state.loginDraft.password;
|
state.registrationDraft.password = state.loginDraft.password;
|
||||||
|
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
|
||||||
|
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||||
state.registrationDraft.sessionId = result.sessionId;
|
state.registrationDraft.sessionId = result.sessionId;
|
||||||
state.registrationDraft.storagePwd = result.storagePwd;
|
state.registrationDraft.storagePwd = result.storagePwd;
|
||||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||||
|
|||||||
@ -4,29 +4,23 @@ export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome:
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack auth-screen auth-screen--lower';
|
||||||
|
|
||||||
const cameraButton = document.createElement('button');
|
|
||||||
cameraButton.className = 'primary-btn';
|
|
||||||
cameraButton.type = 'button';
|
|
||||||
cameraButton.textContent = 'Отсканировать QR-код';
|
|
||||||
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
|
|
||||||
|
|
||||||
const loginButton = document.createElement('button');
|
const loginButton = document.createElement('button');
|
||||||
loginButton.className = 'ghost-btn';
|
loginButton.className = 'ghost-btn';
|
||||||
loginButton.type = 'button';
|
loginButton.type = 'button';
|
||||||
loginButton.textContent = 'Войти по логину';
|
loginButton.textContent = 'Войти по паролю';
|
||||||
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
||||||
|
|
||||||
const otherDeviceButton = document.createElement('button');
|
const otherDeviceButton = document.createElement('button');
|
||||||
otherDeviceButton.className = 'text-btn';
|
otherDeviceButton.className = 'ghost-btn';
|
||||||
otherDeviceButton.type = 'button';
|
otherDeviceButton.type = 'button';
|
||||||
otherDeviceButton.textContent = 'Войти через другое устройство';
|
otherDeviceButton.textContent = 'Войти через другое устройство';
|
||||||
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
|
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-actions login-actions-wide';
|
actions.className = 'auth-actions login-actions-wide';
|
||||||
actions.append(cameraButton, loginButton, otherDeviceButton);
|
actions.append(loginButton, otherDeviceButton);
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
const backButton = document.createElement('button');
|
||||||
backButton.className = 'ghost-btn';
|
backButton.className = 'ghost-btn';
|
||||||
@ -34,13 +28,17 @@ export function render({ navigate }) {
|
|||||||
backButton.textContent = 'Назад';
|
backButton.textContent = 'Назад';
|
||||||
backButton.addEventListener('click', () => navigate('start-view'));
|
backButton.addEventListener('click', () => navigate('start-view'));
|
||||||
|
|
||||||
|
const panel = document.createElement('section');
|
||||||
|
panel.className = 'login-panel stack';
|
||||||
|
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
|
||||||
|
panel.append(actions, backButton);
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Войти',
|
title: 'Войти',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||||
}),
|
}),
|
||||||
actions,
|
panel,
|
||||||
backButton,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
getChatMessages,
|
getChatMessages,
|
||||||
@ -66,7 +65,7 @@ function createDmAvatar(login) {
|
|||||||
|
|
||||||
function formatChatRowTime(ts) {
|
function formatChatRowTime(ts) {
|
||||||
const value = Number(ts || 0);
|
const value = Number(ts || 0);
|
||||||
if (!Number.isFinite(value) || value <= 0) return '-';
|
if (!Number.isFinite(value) || value <= 0) return '';
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@ -75,17 +74,29 @@ function formatChatRowTime(ts) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"/></svg>';
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
|
||||||
screen.append(
|
const head = document.createElement('header');
|
||||||
renderHeader({
|
head.className = 'dm-head';
|
||||||
title: 'Личные сообщения',
|
head.innerHTML = `
|
||||||
leftLabel: String(state.session.login || '').trim(),
|
<div class="dm-head-brand">
|
||||||
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
|
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||||
}),
|
<div class="dm-head-id">
|
||||||
);
|
<span class="dm-head-name">${login}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="dm-head-title">Контакты</h1>
|
||||||
|
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
||||||
|
`;
|
||||||
|
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'dm-divider';
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack dm-list';
|
list.className = 'stack dm-list';
|
||||||
@ -95,20 +106,26 @@ export function render({ navigate }) {
|
|||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
const avatarEl = createDmAvatar(item.id);
|
const avatarEl = createDmAvatar(item.id);
|
||||||
avatarEl.classList.add('avatar');
|
avatarEl.classList.add('avatar');
|
||||||
|
const avatarWrap = document.createElement('div');
|
||||||
|
avatarWrap.className = 'dm-av dm-av--default';
|
||||||
|
avatarWrap.append(avatarEl);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="dm-row-main">
|
<div class="dm-row-main">
|
||||||
<div class="dm-row-title-wrap">
|
<div class="dm-row-titleline dm-row-titlewrap">
|
||||||
<strong class="dm-row-title">${item.name}</strong>
|
<strong class="dm-row-title">${item.name}</strong>
|
||||||
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p>
|
<p class="dm-row-last-message">${item.lastMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-row-meta-col">
|
<div class="dm-row-meta-col">
|
||||||
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
|
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||||
<span class="meta-muted dm-row-time">${item.time}</span>
|
<div class="dm-row-meta-line">
|
||||||
|
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||||
|
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.prepend(avatarEl);
|
row.prepend(avatarWrap);
|
||||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@ -206,7 +223,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
screen.append(list);
|
screen.append(head, divider, list);
|
||||||
loadList();
|
loadList();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
34
shine-UI/js/pages/messages/dm-visual-resolver.js
Normal file
34
shine-UI/js/pages/messages/dm-visual-resolver.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// Экран «Личные сообщения» — единый слой «семантика отношения → визуальное состояние».
|
||||||
|
// messages-list.js только рендерит готовый результат; здесь вся логика выбора тона/статуса/бейджа.
|
||||||
|
// Источник полей — мок directMessages (оффлайн-демо). На проде сюда же подставятся реальные
|
||||||
|
// relationFlagsForTarget / shineConfirmed / shine — UI карточек переписывать не придётся.
|
||||||
|
|
||||||
|
// Тон обода аватара. ВАЖНО: «Подтверждён» НЕ красит обод золотым (золото = семья/близкий круг).
|
||||||
|
// isShining → 'shining' (небесный) ; relationType==='family' → 'family' (золотой) ; иначе 'default' (violet).
|
||||||
|
// toneOverride — только для тестового мока (в проде не задавать).
|
||||||
|
export function resolveAvatarTone(msg) {
|
||||||
|
const o = String(msg?.toneOverride || '').trim();
|
||||||
|
if (o === 'default' || o === 'family' || o === 'shining') return o;
|
||||||
|
if (msg?.isShining) return 'shining';
|
||||||
|
if (msg?.relationType === 'family') return 'family';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Непрочитанные: показываем только при >0; 1–99, далее «99+». Отдельная violet-сфера (НЕ изумруд).
|
||||||
|
export function resolveUnreadStyle(msg) {
|
||||||
|
const n = Math.max(0, Math.trunc(Number(msg?.unreadCount ?? msg?.unread ?? 0)) || 0);
|
||||||
|
if (n <= 0) return null;
|
||||||
|
return { count: n, label: n > 99 ? '99+' : String(n) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Итоговое визуальное состояние карточки.
|
||||||
|
export function resolveDmVisualState(msg) {
|
||||||
|
const via = Array.isArray(msg?.connectedVia) && msg.connectedVia.length ? msg.connectedVia : null;
|
||||||
|
return {
|
||||||
|
tone: resolveAvatarTone(msg), // 'default' | 'family' | 'shining'
|
||||||
|
shining: Boolean(msg?.isShining),
|
||||||
|
confirmed: Boolean(msg?.isConfirmed), // галочка ✓ у имени (без слова «Подтверждён»)
|
||||||
|
via, // путь «через кого»: [{name, photo}, …] | null
|
||||||
|
unread: resolveUnreadStyle(msg), // { count, label } | null
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
import { renderNetworkLab } from './network/lab.js';
|
|
||||||
import { createForceGraph } from './network/force-graph.js';
|
import { createForceGraph } from './network/force-graph.js';
|
||||||
import { engineModelFromGraphModel } from './network/adapter.js';
|
import { engineModelFromGraphModel } from './network/adapter.js';
|
||||||
import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
|
import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
|
||||||
@ -217,10 +216,6 @@ let persistedCenterLogin = '';
|
|||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
// Лабораторный режим force-графа (Фаза 1): рендерится из мока, не трогая реальный путь ниже.
|
|
||||||
if (String(route?.params?.mode || '').trim().toLowerCase() === 'lab') {
|
|
||||||
return renderNetworkLab({ navigate });
|
|
||||||
}
|
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
||||||
|
|
||||||
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
|
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
|
||||||
|
import { state } from '../../state.js';
|
||||||
|
import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
|
||||||
|
|
||||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
@ -33,9 +35,9 @@ const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| дл
|
|||||||
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
||||||
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
||||||
const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк)
|
const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк)
|
||||||
const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
|
const BLOOM_MS = 550; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
|
||||||
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
|
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
|
||||||
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
|
const FOCUS_SCALE = 1.78; // базовый масштаб фокуса — центр крупнее (иерархия, рычаг 2; ±дыхание)
|
||||||
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
||||||
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
||||||
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
|
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
|
||||||
@ -90,6 +92,11 @@ const RELATION_COLORS = {
|
|||||||
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
|
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
|
||||||
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
|
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
|
||||||
|
|
||||||
|
// Радиус видимой сферы орба (world-единицы), синхронно с CSS `.fg-node .node-dot` = 58px → радиус 29
|
||||||
|
// (сфера орба ≈ радиусу node-dot). ЕДИНЫЙ источник радиуса для контакта линий с кромкой и раскладки детей —
|
||||||
|
// меняешь размер орба → меняй здесь и в CSS вместе, линии останутся впритык.
|
||||||
|
const ORB_R = 29;
|
||||||
|
|
||||||
|
|
||||||
function easeOutCubic(t) {
|
function easeOutCubic(t) {
|
||||||
const x = 1 - t;
|
const x = 1 - t;
|
||||||
@ -100,6 +107,26 @@ function relationColor(relationType) {
|
|||||||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAvatarPhotoSrc(src) {
|
||||||
|
const directPhoto = String(src?.photo || '').trim();
|
||||||
|
if (directPhoto) return directPhoto;
|
||||||
|
|
||||||
|
const rawAvatar = src?.avatar;
|
||||||
|
if (!rawAvatar || rawAvatar === 'url_to_image') return null;
|
||||||
|
if (typeof rawAvatar === 'string') return String(rawAvatar).trim() || null;
|
||||||
|
|
||||||
|
const txId = String(rawAvatar?.ar || '').trim();
|
||||||
|
if (!txId) return null;
|
||||||
|
try {
|
||||||
|
return buildArweaveDataUrl({
|
||||||
|
gateway: state?.entrySettings?.arweaveServer,
|
||||||
|
txId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
|
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
|
||||||
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
|
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
|
||||||
function ensureShineFilter() {
|
function ensureShineFilter() {
|
||||||
@ -419,6 +446,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Векторный SVG-орб (buildGlassOrb) ретайрнут 13.06 — все орбы рисует buildPngOrb (PNG-оверлей).
|
||||||
|
|
||||||
|
// A/B-вариант (ветка glass-png-overlay): орб = фото + запечённый стеклянный PNG поверх.
|
||||||
|
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
||||||
|
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
||||||
|
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
|
||||||
|
function buildPngOrb(src, opts) {
|
||||||
|
const o = opts || {};
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'fg-pngorb';
|
||||||
|
function makeInit() {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'fg-pngorb-photo fg-pngorb-init';
|
||||||
|
d.textContent = String(o.initials || '').slice(0, 2);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
let photo;
|
||||||
|
if (src) {
|
||||||
|
photo = document.createElement('img');
|
||||||
|
photo.className = 'fg-pngorb-photo';
|
||||||
|
photo.src = src; photo.alt = '';
|
||||||
|
photo.addEventListener('error', () => { try { photo.replaceWith(makeInit()); } catch (e) { /* fallback */ } });
|
||||||
|
} else {
|
||||||
|
photo = makeInit();
|
||||||
|
}
|
||||||
|
const glass = document.createElement('img');
|
||||||
|
glass.className = 'fg-pngorb-glass';
|
||||||
|
glass.src = GLASS_OVERLAY_SRC; glass.alt = '';
|
||||||
|
glass.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.append(photo, glass);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
@ -447,17 +507,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
el.dataset.nodeId = String(src.id);
|
el.dataset.nodeId = String(src.id);
|
||||||
|
|
||||||
// тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы)
|
// Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
|
||||||
const avatar = src.photo
|
// синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
|
||||||
? buildPhotoAvatar(src)
|
const photoSrc = resolveAvatarPhotoSrc(src);
|
||||||
: renderUserAvatar({
|
const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
|
||||||
login: src.login || src.name || String(src.id),
|
const dot = document.createElement('div');
|
||||||
firstName: src.name || '',
|
dot.className = 'avatar node-dot fg-orb-host';
|
||||||
avatar: src.avatar || null,
|
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
|
||||||
size: 'node',
|
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
|
||||||
title: src.name || src.login || '',
|
el.append(dot);
|
||||||
});
|
|
||||||
el.append(avatar);
|
|
||||||
|
|
||||||
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
|
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
@ -597,7 +655,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель
|
// АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель
|
||||||
// и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей.
|
// и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей.
|
||||||
const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
|
const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
|
||||||
const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
|
const pr = ORB_R * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
|
||||||
const cnt = childCountByParent.get(n.parentId) || 1;
|
const cnt = childCountByParent.get(n.parentId) || 1;
|
||||||
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
|
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
|
||||||
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
|
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
|
||||||
@ -707,7 +765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
|
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
|
||||||
const fx = centerX + camX + parent.x * Z;
|
const fx = centerX + camX + parent.x * Z;
|
||||||
const fy = centerY + camY + parent.y * Z;
|
const fy = centerY + camY + parent.y * Z;
|
||||||
const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4;
|
// радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…),
|
||||||
|
// лёгкая точка (.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 nx = tx(n);
|
||||||
const ny = ty(n);
|
const ny = ty(n);
|
||||||
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||||
@ -724,8 +784,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const ux = dx / len;
|
const ux = dx / len;
|
||||||
const uy = dy / len;
|
const uy = dy / len;
|
||||||
const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4;
|
const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z;
|
||||||
// концы линии — у краёв кружков
|
// концы линии — ровно на кромке сферы орба (радиус ORB_R для полных орбов), без зазора и без захода внутрь
|
||||||
const x1 = fx + ux * fr;
|
const x1 = fx + ux * fr;
|
||||||
const y1 = fy + uy * fr;
|
const y1 = fy + uy * fr;
|
||||||
const x2 = ex - ux * nr;
|
const x2 = ex - ux * nr;
|
||||||
@ -772,14 +832,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
|
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
|
||||||
if (n.tier >= 3) {
|
if (n.tier >= 3) {
|
||||||
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
|
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
||||||
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe * sp).toFixed(2)}" />`);
|
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)}" />`);
|
||||||
|
} 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)}" />`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (n.tier === 2) {
|
} else if (n.tier === 2) {
|
||||||
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
|
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
|
||||||
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe * sp).toFixed(2)}" />`);
|
if (pe > 0.02) {
|
||||||
} else if (shine || n.track || onPath) {
|
if (shine) {
|
||||||
// СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя
|
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" />`);
|
||||||
// с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро.
|
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" 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)}" />`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (shine) {
|
||||||
|
// СИЯЮЩАЯ связь → цвет сияющей линии (плазма). Только сияющим — несияющие (в т.ч. активный путь
|
||||||
|
// погружения track/onPath) идут ниже в ЦВЕТ КАТЕГОРИИ. Плазма: ОДИН S-путь + ТРИ слоя на одном d.
|
||||||
const pnx = -uy;
|
const pnx = -uy;
|
||||||
const pny = ux; // перпендикуляр к хорде
|
const pny = ux; // перпендикуляр к хорде
|
||||||
const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине)
|
const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине)
|
||||||
@ -1181,8 +1255,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
|
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
|
||||||
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
|
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
|
||||||
let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс)
|
let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс)
|
||||||
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
|
// Виброотклик отключён по запросу: на экране «Связи» телефон не вибрирует ни на тапах, ни на переходах.
|
||||||
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
|
// (no-op; вызовы haptic(...) ниже оставлены, но ничего не делают — легко вернуть, восстановив тело.)
|
||||||
|
const haptic = () => {};
|
||||||
|
|
||||||
// Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера.
|
// Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера.
|
||||||
const prefetched = new Set();
|
const prefetched = new Set();
|
||||||
|
|||||||
@ -1,341 +0,0 @@
|
|||||||
// Лабораторный режим карты связей (network-view/lab).
|
|
||||||
//
|
|
||||||
// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование,
|
|
||||||
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
|
|
||||||
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
|
|
||||||
// карту на сеть этого человека (как реальный путь, но локально).
|
|
||||||
//
|
|
||||||
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
|
|
||||||
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
|
|
||||||
// Это чисто визуальный лабораторный эксперимент на мок-данных.
|
|
||||||
|
|
||||||
import { renderHeader } from '../../components/header.js';
|
|
||||||
import { networkGraphUsers } from '../../mock-data.js';
|
|
||||||
import { createForceGraph, buildModelFromTz } from './force-graph.js';
|
|
||||||
import { openNodeMenu } from './node-menu.js';
|
|
||||||
|
|
||||||
const START_LOGIN = 'ivan';
|
|
||||||
|
|
||||||
// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам;
|
|
||||||
// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие».
|
|
||||||
const FILTERS = {
|
|
||||||
all: { label: 'Все', pred: () => true },
|
|
||||||
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
|
|
||||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
|
|
||||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
|
||||||
};
|
|
||||||
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
|
||||||
|
|
||||||
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
|
|
||||||
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
|
|
||||||
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
|
|
||||||
|
|
||||||
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
|
|
||||||
function seed01(str) {
|
|
||||||
let h = 2166136261;
|
|
||||||
const s = String(str || '');
|
|
||||||
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
||||||
return ((h >>> 0) % 100000) / 100000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function helpText() {
|
|
||||||
return [
|
|
||||||
'Лаборатория карты связей (мок-данные, без сервера).',
|
|
||||||
'• Тащите по экрану — карта свободно перемещается (pan).',
|
|
||||||
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
|
|
||||||
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
|
||||||
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
|
||||||
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
|
||||||
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
|
|
||||||
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
|
|
||||||
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
|
|
||||||
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
|
|
||||||
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
|
|
||||||
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
|
|
||||||
' превращаются в аватарки. Свайп — pan.',
|
|
||||||
'',
|
|
||||||
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей).
|
|
||||||
function graphForLogin(login) {
|
|
||||||
const key = String(login || '').trim().toLowerCase();
|
|
||||||
if (networkGraphUsers[key]) return networkGraphUsers[key];
|
|
||||||
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
|
|
||||||
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
|
|
||||||
function synthTier1(focusId) {
|
|
||||||
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
|
|
||||||
const out = [];
|
|
||||||
for (let i = 0; i < k; i += 1) {
|
|
||||||
const id = `${focusId}__t1_${i}`;
|
|
||||||
const s = seed01(id);
|
|
||||||
out.push({
|
|
||||||
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
|
|
||||||
avatar: null, photo: null,
|
|
||||||
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
|
|
||||||
connectionStrength: 0.5 + s * 0.4,
|
|
||||||
status: s > 0.78 ? 'shining' : '',
|
|
||||||
hasOwnConnections: true, tier: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
|
|
||||||
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
|
|
||||||
function addDeepLevels(model) {
|
|
||||||
const focusId = model.focusId;
|
|
||||||
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
|
|
||||||
const extra = [];
|
|
||||||
tier1.forEach((p) => {
|
|
||||||
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
|
|
||||||
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
|
|
||||||
const others = tier1.filter((o) => String(o.id) !== String(p.id));
|
|
||||||
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
|
|
||||||
for (let i = 0; i < k2; i += 1) {
|
|
||||||
const id2 = `${p.id}__d2_${i}`;
|
|
||||||
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
|
|
||||||
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
|
|
||||||
if (i === 0 && common) {
|
|
||||||
extra.push({
|
|
||||||
id: id2, login: id2, name: common.name || common.login,
|
|
||||||
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
|
|
||||||
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
|
|
||||||
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
|
|
||||||
extra.push({
|
|
||||||
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
|
|
||||||
avatar: null, photo: face2, relationType: p.relationType || 'contact',
|
|
||||||
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
|
|
||||||
});
|
|
||||||
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
|
|
||||||
for (let j = 0; j < k3; j += 1) {
|
|
||||||
const id3 = `${id2}_d3_${j}`;
|
|
||||||
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
|
|
||||||
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
|
|
||||||
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
|
|
||||||
extra.push({
|
|
||||||
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
|
|
||||||
avatar: null, photo: face3, relationType: 'contact',
|
|
||||||
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { focusId, nodes: [...model.nodes, ...extra] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
|
|
||||||
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
|
|
||||||
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
|
|
||||||
function buildLabModel(login, deep, fromLogin) {
|
|
||||||
const tz = graphForLogin(login);
|
|
||||||
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
|
|
||||||
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
|
|
||||||
else tz.connections = [];
|
|
||||||
}
|
|
||||||
const base = buildModelFromTz(tz);
|
|
||||||
|
|
||||||
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
|
|
||||||
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
|
|
||||||
const fid = String(fromLogin);
|
|
||||||
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|
|
||||||
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
|
|
||||||
if (found) {
|
|
||||||
found.track = true; // уже среди связей — просто подсветим трек
|
|
||||||
} else {
|
|
||||||
const f = graphForLogin(fromLogin).focusUser || {};
|
|
||||||
base.nodes.push({
|
|
||||||
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
|
|
||||||
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
|
|
||||||
photo: f.photo || null, relationType: 'friend', strength: 0.97,
|
|
||||||
shining: false, tier: 1, track: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deep ? addDeepLevels(base) : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderNetworkLab({ navigate }) {
|
|
||||||
const screen = document.createElement('section');
|
|
||||||
screen.className = 'network-screen';
|
|
||||||
|
|
||||||
const appScreenEl = document.getElementById('app-screen');
|
|
||||||
appScreenEl?.classList.add('network-scroll-lock');
|
|
||||||
|
|
||||||
const stage = document.createElement('div');
|
|
||||||
stage.className = 'network-stage fg-stage';
|
|
||||||
|
|
||||||
const header = renderHeader({
|
|
||||||
title: 'Связи · лаборатория',
|
|
||||||
leftAction: { label: '←', onClick: () => navigate('network-view') },
|
|
||||||
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
|
|
||||||
});
|
|
||||||
header.classList.add('network-header-overlay');
|
|
||||||
|
|
||||||
let centerLogin = START_LOGIN;
|
|
||||||
let deepMode = false;
|
|
||||||
|
|
||||||
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
|
||||||
let activeFilter = 'all';
|
|
||||||
const filterChips = {};
|
|
||||||
function applyFilter(key) {
|
|
||||||
if (!FILTERS[key]) return;
|
|
||||||
activeFilter = key;
|
|
||||||
FILTER_ORDER.forEach((k) => {
|
|
||||||
const el = filterChips[k];
|
|
||||||
if (el) el.classList.toggle('is-active', k === activeFilter);
|
|
||||||
});
|
|
||||||
graph.setFilter(FILTERS[key].pred);
|
|
||||||
}
|
|
||||||
|
|
||||||
stage.append(header);
|
|
||||||
screen.append(stage);
|
|
||||||
|
|
||||||
const model = buildLabModel(centerLogin, deepMode);
|
|
||||||
|
|
||||||
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
|
|
||||||
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
|
|
||||||
|
|
||||||
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
|
||||||
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
|
||||||
const graph = createForceGraph({
|
|
||||||
stage,
|
|
||||||
model,
|
|
||||||
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
|
|
||||||
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
|
||||||
onNodeTap: (node) => {
|
|
||||||
if (deepMode) {
|
|
||||||
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
|
|
||||||
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
|
|
||||||
graph.diveTo(node);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
|
|
||||||
const from = centerLogin;
|
|
||||||
centerLogin = node.login || node.id;
|
|
||||||
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
|
||||||
},
|
|
||||||
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
|
|
||||||
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
|
|
||||||
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
|
|
||||||
// Изменение пути погружения → перерисовываем хлебные крошки (Иван › Нина › Ада).
|
|
||||||
onDiveChange: (path) => renderBreadcrumb(path),
|
|
||||||
onCenterTap: (node) => {
|
|
||||||
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
|
||||||
if (deepMode) { graph.collapseAll(); return; }
|
|
||||||
window.alert(`Профиль: ${node.name || node.login || node.id}`);
|
|
||||||
},
|
|
||||||
onNodeLongPress: (node, point) => openNodeMenu({
|
|
||||||
login: node.name || node.login || node.id,
|
|
||||||
relationType: node.relationType,
|
|
||||||
point,
|
|
||||||
actions: [
|
|
||||||
{ label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) },
|
|
||||||
{ label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
|
||||||
const filterBar = document.createElement('div');
|
|
||||||
filterBar.className = 'fg-filter-bar';
|
|
||||||
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
|
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
||||||
FILTER_ORDER.forEach((key) => {
|
|
||||||
const chip = document.createElement('button');
|
|
||||||
chip.type = 'button';
|
|
||||||
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
|
|
||||||
chip.textContent = FILTERS[key].label;
|
|
||||||
chip.addEventListener('click', () => applyFilter(key));
|
|
||||||
filterChips[key] = chip;
|
|
||||||
filterBar.append(chip);
|
|
||||||
});
|
|
||||||
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
|
|
||||||
const deepChip = document.createElement('button');
|
|
||||||
deepChip.type = 'button';
|
|
||||||
deepChip.className = 'fg-filter-chip fg-deep-chip';
|
|
||||||
deepChip.textContent = '🌌 Вселенная';
|
|
||||||
deepChip.addEventListener('click', () => {
|
|
||||||
deepMode = !deepMode;
|
|
||||||
deepChip.classList.toggle('is-active', deepMode);
|
|
||||||
graph.setModel(buildLabModel(centerLogin, deepMode));
|
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
|
||||||
});
|
|
||||||
filterBar.append(deepChip);
|
|
||||||
stage.append(filterBar);
|
|
||||||
|
|
||||||
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
|
|
||||||
const searchWrap = document.createElement('div');
|
|
||||||
searchWrap.className = 'fg-search';
|
|
||||||
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
||||||
const searchIco = document.createElement('span');
|
|
||||||
searchIco.className = 'fg-search-ico';
|
|
||||||
searchIco.textContent = '🔍';
|
|
||||||
const searchInput = document.createElement('input');
|
|
||||||
searchInput.type = 'search';
|
|
||||||
searchInput.placeholder = 'Найти человека…';
|
|
||||||
searchInput.setAttribute('aria-label', 'Поиск по имени');
|
|
||||||
function doSearch() {
|
|
||||||
const hit = graph.findNode(searchInput.value);
|
|
||||||
if (!hit) return;
|
|
||||||
if (deepMode) {
|
|
||||||
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
|
|
||||||
} else {
|
|
||||||
const from = centerLogin;
|
|
||||||
centerLogin = hit.id;
|
|
||||||
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
|
||||||
}
|
|
||||||
searchInput.blur();
|
|
||||||
}
|
|
||||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
|
|
||||||
searchWrap.append(searchIco, searchInput);
|
|
||||||
stage.append(searchWrap);
|
|
||||||
|
|
||||||
// --- Хлебные крошки: стек погружений (Иван › Нина › Ада); клик по крошке — навигация назад ---
|
|
||||||
breadcrumbEl = document.createElement('div');
|
|
||||||
breadcrumbEl.className = 'fg-breadcrumb';
|
|
||||||
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
||||||
stage.append(breadcrumbEl);
|
|
||||||
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
|
|
||||||
function renderBreadcrumb(path) {
|
|
||||||
if (!breadcrumbEl) return;
|
|
||||||
breadcrumbEl.innerHTML = '';
|
|
||||||
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
|
|
||||||
breadcrumbEl.classList.toggle('is-open', open);
|
|
||||||
if (!open) return;
|
|
||||||
path.forEach((p, i) => {
|
|
||||||
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = '›'; breadcrumbEl.append(sep); }
|
|
||||||
const c = document.createElement('button');
|
|
||||||
c.type = 'button';
|
|
||||||
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
|
|
||||||
c.textContent = p.name;
|
|
||||||
if (i < path.length - 1) {
|
|
||||||
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
|
|
||||||
}
|
|
||||||
breadcrumbEl.append(c);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
|
|
||||||
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
|
|
||||||
window.__fg = graph;
|
|
||||||
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
|
|
||||||
}
|
|
||||||
|
|
||||||
screen.cleanup = () => {
|
|
||||||
graph.destroy();
|
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
|
||||||
};
|
|
||||||
|
|
||||||
return screen;
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
// Автопроверки интерактивного графа связей (dev-only).
|
|
||||||
//
|
|
||||||
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
|
|
||||||
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
|
|
||||||
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
|
|
||||||
//
|
|
||||||
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
|
|
||||||
|
|
||||||
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
|
|
||||||
|
|
||||||
export async function runNetworkSelfTest(graph, deepChipEl) {
|
|
||||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
const results = [];
|
|
||||||
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
|
|
||||||
const st = () => graph.debugState();
|
|
||||||
|
|
||||||
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
|
|
||||||
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
|
|
||||||
await wait(1700);
|
|
||||||
|
|
||||||
let s = st();
|
|
||||||
const focusId = s.focusId;
|
|
||||||
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
|
|
||||||
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
|
|
||||||
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
|
|
||||||
|
|
||||||
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
|
|
||||||
graph.diveTo({ id: parent.id });
|
|
||||||
const framesA = graph.pumpForTest();
|
|
||||||
s = st();
|
|
||||||
const p = s.nodes.find((n) => n.id === parent.id);
|
|
||||||
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
|
|
||||||
|
|
||||||
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
|
|
||||||
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
|
|
||||||
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
|
|
||||||
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
|
|
||||||
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
|
|
||||||
|
|
||||||
// расталкивание: дети не слипаются
|
|
||||||
let minD = Infinity;
|
|
||||||
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
|
|
||||||
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
|
|
||||||
}
|
|
||||||
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
|
|
||||||
|
|
||||||
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
|
|
||||||
const outward = Math.atan2(p.y, p.x);
|
|
||||||
const maxDev = kids.reduce((mx, k) => {
|
|
||||||
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
|
|
||||||
if (d > Math.PI) d = 2 * Math.PI - d;
|
|
||||||
return Math.max(mx, d * 180 / Math.PI);
|
|
||||||
}, 0);
|
|
||||||
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
|
|
||||||
|
|
||||||
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
|
|
||||||
const offPath = tier1.filter((n) => n.id !== parent.id);
|
|
||||||
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
|
|
||||||
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
|
|
||||||
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
|
|
||||||
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
|
|
||||||
|
|
||||||
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
|
|
||||||
if (offPath.length) {
|
|
||||||
graph.diveTo({ id: offPath[0].id });
|
|
||||||
graph.pumpForTest();
|
|
||||||
s = st();
|
|
||||||
const prev = s.nodes.find((n) => n.id === parent.id);
|
|
||||||
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
|
|
||||||
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
|
|
||||||
const t2withKids = st().nodes.find((n) => n.tier === 2);
|
|
||||||
if (t2withKids) {
|
|
||||||
graph.diveTo({ id: t2withKids.id });
|
|
||||||
graph.pumpForTest();
|
|
||||||
s = st();
|
|
||||||
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
|
|
||||||
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
|
|
||||||
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
|
|
||||||
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
|
|
||||||
if (named && typeof graph.findNode === 'function') {
|
|
||||||
const byId = graph.findNode(named.id);
|
|
||||||
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
|
|
||||||
if (typeof graph.getDivePath === 'function' && t2withKids) {
|
|
||||||
const path = graph.getDivePath();
|
|
||||||
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
|
|
||||||
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
|
|
||||||
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
|
|
||||||
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) ===
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const commonCount = document.querySelectorAll('.fg-node.is-common').length;
|
|
||||||
check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест J: доступность — текстовый список графа для скринридеров ===
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const a11y = document.querySelector('.fg-a11y');
|
|
||||||
const liCount = a11y ? a11y.querySelectorAll('li').length : 0;
|
|
||||||
check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
|
|
||||||
graph.exitDive();
|
|
||||||
graph.pumpForTest();
|
|
||||||
s = st();
|
|
||||||
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
|
|
||||||
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
|
|
||||||
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
|
|
||||||
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
|
|
||||||
|
|
||||||
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const flare = document.querySelectorAll('.fg-plasma-flare');
|
|
||||||
const tube = document.querySelectorAll('.fg-plasma-tube');
|
|
||||||
const core = document.querySelectorAll('.fg-plasma-core');
|
|
||||||
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
|
|
||||||
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
|
|
||||||
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
|
|
||||||
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finish(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish(results) {
|
|
||||||
const pass = results.filter((r) => r.pass).length;
|
|
||||||
const out = { pass, total: results.length, results };
|
|
||||||
if (typeof window !== 'undefined') window.__fgTestResults = out;
|
|
||||||
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
|
|
||||||
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, state } from '../state.js';
|
import { authService, clearAuthMessages, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
@ -6,9 +6,59 @@ import {
|
|||||||
formatSolanaErrorDetails,
|
formatSolanaErrorDetails,
|
||||||
precheckLoginClassOnSolana,
|
precheckLoginClassOnSolana,
|
||||||
} from '../services/solana-register-service.js';
|
} from '../services/solana-register-service.js';
|
||||||
|
import {
|
||||||
|
composePasswordFromWords,
|
||||||
|
emptyPasswordWords,
|
||||||
|
normalizePasswordWords,
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_WORDS_COUNT,
|
||||||
|
} from '../services/password-words.js';
|
||||||
|
import { openRegistrationFaq, REGISTRATION_FAQ_TOPICS } from './registration-faq-view.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||||
|
|
||||||
|
function createWordsLayout({ words, onInput }) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'registration-words-block';
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'registration-words-grid';
|
||||||
|
|
||||||
|
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'registration-word-row';
|
||||||
|
|
||||||
|
const number = document.createElement('span');
|
||||||
|
number.className = 'registration-word-number';
|
||||||
|
number.textContent = `${index + 1}.`;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'input registration-word-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.autocapitalize = 'off';
|
||||||
|
input.spellcheck = false;
|
||||||
|
input.maxLength = 32;
|
||||||
|
input.value = words[index];
|
||||||
|
input.addEventListener('input', () => onInput(index, input.value));
|
||||||
|
|
||||||
|
row.append(number, input);
|
||||||
|
grid.append(row);
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.className = 'meta-muted';
|
||||||
|
hint.textContent =
|
||||||
|
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
|
||||||
|
|
||||||
|
const preview = document.createElement('p');
|
||||||
|
preview.className = 'status-line';
|
||||||
|
|
||||||
|
section.append(grid, hint, preview);
|
||||||
|
return { section, inputs, preview };
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -18,6 +68,9 @@ export function render({ navigate }) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
|
|
||||||
|
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||||
|
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
|
||||||
|
|
||||||
const loginInput = document.createElement('input');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
@ -34,8 +87,33 @@ export function render({ navigate }) {
|
|||||||
passwordInput.autocomplete = 'new-password';
|
passwordInput.autocomplete = 'new-password';
|
||||||
passwordInput.autocapitalize = 'off';
|
passwordInput.autocapitalize = 'off';
|
||||||
passwordInput.spellcheck = false;
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.registrationDraft.password;
|
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
passwordInput.value = passwordMode === 'single' ? state.registrationDraft.password : '';
|
||||||
|
passwordInput.placeholder = 'Введите пароль';
|
||||||
|
|
||||||
|
const {
|
||||||
|
section: wordsSection,
|
||||||
|
inputs: wordInputs,
|
||||||
|
preview: wordsPreview,
|
||||||
|
} = createWordsLayout({
|
||||||
|
words: passwordWords,
|
||||||
|
onInput: (index, value) => {
|
||||||
|
passwordWords[index] = value;
|
||||||
|
syncDraftState();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordModeToggle = document.createElement('label');
|
||||||
|
passwordModeToggle.className = 'registration-toggle';
|
||||||
|
|
||||||
|
const passwordModeCheckbox = document.createElement('input');
|
||||||
|
passwordModeCheckbox.type = 'checkbox';
|
||||||
|
passwordModeCheckbox.checked = passwordMode === 'words';
|
||||||
|
|
||||||
|
const passwordModeLabel = document.createElement('span');
|
||||||
|
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
|
||||||
|
|
||||||
|
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
|
||||||
|
|
||||||
const statusText = document.createElement('p');
|
const statusText = document.createElement('p');
|
||||||
statusText.className = 'meta-muted';
|
statusText.className = 'meta-muted';
|
||||||
@ -47,10 +125,32 @@ export function render({ navigate }) {
|
|||||||
<p class="field-label">Первый сервер SHiNE</p>
|
<p class="field-label">Первый сервер SHiNE</p>
|
||||||
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
|
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
|
||||||
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
|
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
|
||||||
<p class="meta-muted">При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.</p>
|
<p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p>
|
||||||
<p class="meta-muted">При желании его можно изменить в настройках до регистрации или позже. В будущем будет поддерживаться мультисерверная модель, но сейчас используется один основной сервер.</p>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const faqCard = document.createElement('div');
|
||||||
|
faqCard.className = 'card stack registration-faq-card';
|
||||||
|
|
||||||
|
const faqTitle = document.createElement('p');
|
||||||
|
faqTitle.className = 'field-label';
|
||||||
|
faqTitle.textContent = 'Частые вопросы перед регистрацией';
|
||||||
|
|
||||||
|
const faqText = document.createElement('p');
|
||||||
|
faqText.className = 'meta-muted';
|
||||||
|
faqText.textContent = 'Нажмите на вопрос, чтобы открыть отдельный экран с кратким объяснением.';
|
||||||
|
|
||||||
|
const faqButtons = document.createElement('div');
|
||||||
|
faqButtons.className = 'registration-faq-grid';
|
||||||
|
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'ghost-btn';
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = topic.shortTitle;
|
||||||
|
button.addEventListener('click', () => openRegistrationFaq(navigate, topic.id));
|
||||||
|
faqButtons.append(button);
|
||||||
|
});
|
||||||
|
faqCard.append(faqTitle, faqText, faqButtons);
|
||||||
|
|
||||||
const formError = document.createElement('p');
|
const formError = document.createElement('p');
|
||||||
formError.className = 'status-line is-unavailable';
|
formError.className = 'status-line is-unavailable';
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
@ -59,11 +159,11 @@ export function render({ navigate }) {
|
|||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<summary>Расширенные</summary>
|
||||||
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
<p class="meta-muted">Схема деривации: логин и пароль проходят через Argon2id, после чего получается главный секрет.</p>
|
||||||
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
|
<p class="meta-muted">Из этого секрета строятся три ключа: root key для основной публичной записи и важных изменений, blockchain key для подписания действий SHiNE в блокчейне, device key для входа и работы конкретного устройства.</p>
|
||||||
<p class="meta-muted">Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
|
<p class="meta-muted">Разделение нужно, чтобы можно было аккуратнее выдавать права устройствам. Но если у вас нет большой суммы на счёте и нет повышенного риска, обычно можно просто хранить всё на своём устройстве.</p>
|
||||||
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
|
<p class="meta-muted">Режим 12 слов не меняет формат пароля и не меняет API: слова просто склеиваются в одну строку длиной до 256 символов.</p>
|
||||||
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
const checkButton = document.createElement('button');
|
||||||
@ -71,11 +171,49 @@ export function render({ navigate }) {
|
|||||||
checkButton.type = 'button';
|
checkButton.type = 'button';
|
||||||
checkButton.textContent = 'Проверить логин';
|
checkButton.textContent = 'Проверить логин';
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'auth-footer-actions';
|
||||||
|
|
||||||
|
const backButton = document.createElement('button');
|
||||||
|
backButton.className = 'ghost-btn';
|
||||||
|
backButton.type = 'button';
|
||||||
|
backButton.textContent = 'Назад';
|
||||||
|
backButton.addEventListener('click', () => navigate('start-view'));
|
||||||
|
|
||||||
|
const nextButton = document.createElement('button');
|
||||||
|
nextButton.className = 'primary-btn';
|
||||||
|
nextButton.type = 'button';
|
||||||
|
nextButton.textContent = 'Далее';
|
||||||
|
|
||||||
let lastCheckedLogin = '';
|
let lastCheckedLogin = '';
|
||||||
let lastCheckedFree = false;
|
let lastCheckedFree = false;
|
||||||
let lastCheckedClassName = '';
|
let lastCheckedClassName = '';
|
||||||
let generationRunId = 0;
|
let generationRunId = 0;
|
||||||
|
|
||||||
|
function getCurrentPassword() {
|
||||||
|
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWordsPreview() {
|
||||||
|
const password = composePasswordFromWords(passwordWords);
|
||||||
|
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||||
|
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePasswordModeVisibility() {
|
||||||
|
const wordsMode = passwordMode === 'words';
|
||||||
|
wordsSection.hidden = !wordsMode;
|
||||||
|
passwordInput.parentElement.hidden = wordsMode;
|
||||||
|
updateWordsPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDraftState() {
|
||||||
|
state.registrationDraft.login = String(loginInput.value.trim());
|
||||||
|
state.registrationDraft.passwordMode = passwordMode;
|
||||||
|
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||||
|
state.registrationDraft.password = getCurrentPassword();
|
||||||
|
}
|
||||||
|
|
||||||
async function runAvailabilityCheck() {
|
async function runAvailabilityCheck() {
|
||||||
const login = loginInput.value.trim();
|
const login = loginInput.value.trim();
|
||||||
if (!login) {
|
if (!login) {
|
||||||
@ -87,19 +225,19 @@ export function render({ navigate }) {
|
|||||||
if (login === lastCheckedLogin) {
|
if (login === lastCheckedLogin) {
|
||||||
if (!lastCheckedFree) {
|
if (!lastCheckedFree) {
|
||||||
statusText.textContent = 'Логин уже занят ❌';
|
statusText.textContent = 'Логин уже занят ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (lastCheckedClassName === 'free') {
|
} else if (lastCheckedClassName === 'free') {
|
||||||
statusText.textContent = 'Логин свободен ✅';
|
statusText.textContent = 'Логин свободен ✅';
|
||||||
statusText.className = 'is-available';
|
statusText.className = 'status-line is-available';
|
||||||
} else if (lastCheckedClassName === 'premium') {
|
} else if (lastCheckedClassName === 'premium') {
|
||||||
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (lastCheckedClassName === 'company') {
|
} else if (lastCheckedClassName === 'company') {
|
||||||
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else {
|
} else {
|
||||||
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
}
|
}
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
return lastCheckedFree && lastCheckedClassName === 'free';
|
return lastCheckedFree && lastCheckedClassName === 'free';
|
||||||
@ -132,21 +270,21 @@ export function render({ navigate }) {
|
|||||||
lastCheckedClassName = className;
|
lastCheckedClassName = className;
|
||||||
if (!isFree) {
|
if (!isFree) {
|
||||||
statusText.textContent = 'Логин уже занят ❌';
|
statusText.textContent = 'Логин уже занят ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (className === 'free') {
|
} else if (className === 'free') {
|
||||||
statusText.textContent = precheckWarning
|
statusText.textContent = precheckWarning
|
||||||
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
|
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
|
||||||
: 'Логин свободен ✅';
|
: 'Логин свободен ✅';
|
||||||
statusText.className = 'is-available';
|
statusText.className = 'status-line is-available';
|
||||||
} else if (className === 'premium') {
|
} else if (className === 'premium') {
|
||||||
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (className === 'company') {
|
} else if (className === 'company') {
|
||||||
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else {
|
} else {
|
||||||
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
}
|
}
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
return isFree && className === 'free';
|
return isFree && className === 'free';
|
||||||
@ -154,7 +292,7 @@ export function render({ navigate }) {
|
|||||||
const base = toUserMessage(error, 'Не удалось проверить логин');
|
const base = toUserMessage(error, 'Не удалось проверить логин');
|
||||||
const details = formatSolanaErrorDetails(error);
|
const details = formatSolanaErrorDetails(error);
|
||||||
statusText.textContent = `${base}. Детали: ${details}`;
|
statusText.textContent = `${base}. Детали: ${details}`;
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
checkButton.disabled = false;
|
checkButton.disabled = false;
|
||||||
@ -164,19 +302,32 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
checkButton.addEventListener('click', runAvailabilityCheck);
|
checkButton.addEventListener('click', runAvailabilityCheck);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
loginInput.addEventListener('input', () => {
|
||||||
actions.className = 'auth-footer-actions';
|
syncDraftState();
|
||||||
|
lastCheckedLogin = '';
|
||||||
|
});
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
passwordInput.addEventListener('input', () => {
|
||||||
backButton.className = 'ghost-btn';
|
syncDraftState();
|
||||||
backButton.type = 'button';
|
});
|
||||||
backButton.textContent = 'Назад';
|
|
||||||
backButton.addEventListener('click', () => navigate('start-view'));
|
passwordModeCheckbox.addEventListener('change', () => {
|
||||||
|
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
|
||||||
|
if (nextMode === passwordMode) return;
|
||||||
|
if (nextMode === 'words') {
|
||||||
|
passwordWords = emptyPasswordWords();
|
||||||
|
wordInputs.forEach((input) => {
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
passwordInput.value = '';
|
||||||
|
} else {
|
||||||
|
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||||
|
}
|
||||||
|
passwordMode = nextMode;
|
||||||
|
updatePasswordModeVisibility();
|
||||||
|
syncDraftState();
|
||||||
|
});
|
||||||
|
|
||||||
const nextButton = document.createElement('button');
|
|
||||||
nextButton.className = 'primary-btn';
|
|
||||||
nextButton.type = 'button';
|
|
||||||
nextButton.textContent = 'Далее';
|
|
||||||
nextButton.addEventListener('click', async () => {
|
nextButton.addEventListener('click', async () => {
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
const isFree = await runAvailabilityCheck();
|
const isFree = await runAvailabilityCheck();
|
||||||
@ -185,16 +336,23 @@ export function render({ navigate }) {
|
|||||||
const prevLogin = String(state.registrationDraft.login || '');
|
const prevLogin = String(state.registrationDraft.login || '');
|
||||||
const prevPassword = String(state.registrationDraft.password || '');
|
const prevPassword = String(state.registrationDraft.password || '');
|
||||||
const nextLogin = String(loginInput.value.trim());
|
const nextLogin = String(loginInput.value.trim());
|
||||||
const nextPassword = String(passwordInput.value || '');
|
const nextPassword = getCurrentPassword();
|
||||||
if (nextPassword.length === 0) {
|
if (nextPassword.length === 0) {
|
||||||
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
|
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
|
||||||
formError.style.display = '';
|
formError.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (nextPassword.length > PASSWORD_MAX_LENGTH) {
|
||||||
|
formError.textContent = `Пароль получился слишком длинным. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||||
|
formError.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
|
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
|
||||||
|
|
||||||
state.registrationDraft.login = nextLogin;
|
state.registrationDraft.login = nextLogin;
|
||||||
state.registrationDraft.password = nextPassword;
|
state.registrationDraft.password = nextPassword;
|
||||||
|
state.registrationDraft.passwordMode = passwordMode;
|
||||||
|
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||||
if (credsChanged) {
|
if (credsChanged) {
|
||||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||||
}
|
}
|
||||||
@ -202,20 +360,18 @@ export function render({ navigate }) {
|
|||||||
renderSecurityConfirmStage();
|
renderSecurityConfirmStage();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(backButton, nextButton);
|
|
||||||
|
|
||||||
function renderInputStage() {
|
function renderInputStage() {
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
|
||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(serverNotice, checkButton, statusText, advanced, formError);
|
form.append(passwordModeToggle, wordsSection, serverNotice, checkButton, statusText, faqCard, advanced, formError);
|
||||||
actions.innerHTML = '';
|
actions.innerHTML = '';
|
||||||
actions.append(backButton, nextButton);
|
actions.append(backButton, nextButton);
|
||||||
backButton.disabled = false;
|
updatePasswordModeVisibility();
|
||||||
nextButton.disabled = false;
|
syncDraftState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSecurityConfirmStage() {
|
function renderSecurityConfirmStage() {
|
||||||
@ -223,8 +379,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const info = document.createElement('p');
|
const info = document.createElement('p');
|
||||||
info.className = 'auth-copy';
|
info.className = 'auth-copy';
|
||||||
info.textContent =
|
info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.';
|
||||||
'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.';
|
|
||||||
|
|
||||||
const details = document.createElement('p');
|
const details = document.createElement('p');
|
||||||
details.className = 'meta-muted';
|
details.className = 'meta-muted';
|
||||||
@ -232,14 +387,17 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const details2 = document.createElement('p');
|
const details2 = document.createElement('p');
|
||||||
details2.className = 'meta-muted';
|
details2.className = 'meta-muted';
|
||||||
details2.textContent =
|
details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
|
||||||
'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
|
|
||||||
|
|
||||||
const details3 = document.createElement('p');
|
const details3 = document.createElement('p');
|
||||||
details3.className = 'meta-muted';
|
details3.className = 'meta-muted';
|
||||||
details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.';
|
details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.';
|
||||||
|
|
||||||
form.append(info, details, details2, details3);
|
const details4 = document.createElement('p');
|
||||||
|
details4.className = 'meta-muted';
|
||||||
|
details4.textContent = `Длина вашего текущего пароля: ${getCurrentPassword().length} символов.`;
|
||||||
|
|
||||||
|
form.append(info, details, details2, details3, details4);
|
||||||
|
|
||||||
const back2 = document.createElement('button');
|
const back2 = document.createElement('button');
|
||||||
back2.className = 'ghost-btn';
|
back2.className = 'ghost-btn';
|
||||||
@ -270,17 +428,10 @@ export function render({ navigate }) {
|
|||||||
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
|
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
|
||||||
|
|
||||||
const progressWrap = document.createElement('div');
|
const progressWrap = document.createElement('div');
|
||||||
progressWrap.style.width = '100%';
|
progressWrap.className = 'registration-progress';
|
||||||
progressWrap.style.height = '10px';
|
|
||||||
progressWrap.style.border = '1px solid rgba(180,180,180,.5)';
|
|
||||||
progressWrap.style.borderRadius = '6px';
|
|
||||||
progressWrap.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
const progressBar = document.createElement('div');
|
const progressBar = document.createElement('div');
|
||||||
progressBar.style.height = '100%';
|
progressBar.className = 'registration-progress-bar';
|
||||||
progressBar.style.width = '0%';
|
|
||||||
progressBar.style.background = 'rgba(80, 160, 255, 0.9)';
|
|
||||||
progressBar.style.transition = 'width 180ms linear';
|
|
||||||
progressWrap.append(progressBar);
|
progressWrap.append(progressBar);
|
||||||
|
|
||||||
const progressText = document.createElement('p');
|
const progressText = document.createElement('p');
|
||||||
|
|||||||
229
shine-UI/js/pages/registration-faq-view.js
Normal file
229
shine-UI/js/pages/registration-faq-view.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
|
||||||
|
|
||||||
|
export const REGISTRATION_FAQ_TOPICS = [
|
||||||
|
{
|
||||||
|
id: 'keys-storage',
|
||||||
|
shortTitle: 'Где ключи',
|
||||||
|
title: 'У кого хранятся ключи?',
|
||||||
|
paragraphs: [
|
||||||
|
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
|
||||||
|
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
|
||||||
|
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reliability',
|
||||||
|
shortTitle: 'Надёжность',
|
||||||
|
title: 'Насколько это надёжно?',
|
||||||
|
paragraphs: [
|
||||||
|
'Мы делаем ставку на открытость: клиентский код открыт, серверный код открыт, протокол открыт. Это позволяет проверять систему независимо, а не верить обещаниям на слово.',
|
||||||
|
'Мы рекомендуем использовать браузеры с открытым исходным кодом. Позже планируются отдельные приложения для Android, iPhone, Ubuntu Touch и Linux, тоже с открытым кодом.',
|
||||||
|
'Проект распространяется по лицензии AGPL v3. Часть важных данных и регистрационных записей также опирается на блокчейн-слой, чтобы уменьшать зависимость от одной закрытой стороны.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'key-derivation',
|
||||||
|
shortTitle: 'Деривация',
|
||||||
|
title: 'Как генерируются ключи и что делает пароль?',
|
||||||
|
paragraphs: [
|
||||||
|
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
|
||||||
|
'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.',
|
||||||
|
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'three-keys',
|
||||||
|
shortTitle: 'Три ключа',
|
||||||
|
title: 'Зачем нужны три ключа?',
|
||||||
|
paragraphs: [
|
||||||
|
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
|
||||||
|
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
|
||||||
|
'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
|
||||||
|
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'generation-time',
|
||||||
|
shortTitle: 'Зачем время',
|
||||||
|
title: 'Зачем нужна заметная пауза при генерации?',
|
||||||
|
paragraphs: [
|
||||||
|
'Генерация специально сделана не мгновенной. Это усложняет массовый подбор паролей.',
|
||||||
|
'Argon2id расходует и время, и память, поэтому атаки на GPU и видеокартах становятся заметно дороже и медленнее.',
|
||||||
|
'Небольшая задержка при создании секрета здесь работает как дополнительная защита.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'strong-password',
|
||||||
|
shortTitle: 'Какой пароль',
|
||||||
|
title: 'Какой пароль считается надёжным?',
|
||||||
|
paragraphs: [
|
||||||
|
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
|
||||||
|
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
|
||||||
|
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'one-or-twelve',
|
||||||
|
shortTitle: '1 или 12 слов',
|
||||||
|
title: 'Чем отличается один пароль от режима 12 слов?',
|
||||||
|
paragraphs: [
|
||||||
|
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
|
||||||
|
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
|
||||||
|
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'why-own-password',
|
||||||
|
shortTitle: 'Зачем свой',
|
||||||
|
title: 'Почему лучше иметь свой пароль и свои ключи?',
|
||||||
|
paragraphs: [
|
||||||
|
'Чем дальше, тем проще будет подделывать фотографию, голос, интонацию и даже другие привычные признаки личности с помощью нейросетей.',
|
||||||
|
'На расстоянии всё сложнее будет понять, что перед вами действительно вы, если опираться только на внешние признаки.',
|
||||||
|
'Поэтому персональные ключи, которые храните только вы, становятся надёжнее, чем зависимость от сторонней организации, которая держит ключи у себя.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first-server',
|
||||||
|
shortTitle: 'Первый сервер',
|
||||||
|
title: 'Что такое первый сервер SHiNE?',
|
||||||
|
paragraphs: [
|
||||||
|
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
|
||||||
|
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
|
||||||
|
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
|
||||||
|
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hardware-device',
|
||||||
|
shortTitle: 'ESP32',
|
||||||
|
title: 'Нужно ли отдельное устройство для ключей?',
|
||||||
|
paragraphs: [
|
||||||
|
'Идеальный вариант для важных ключей: отдельное физическое устройство, которое вы контролируете сами.',
|
||||||
|
'Если пока не хотите покупать отдельное устройство, можно пользоваться телефоном. Но отдельный контроллер или мини-устройство обычно даёт лучший контроль и более понятную модель доверия.',
|
||||||
|
'Красивая готовая модель Waveshare на ESP32-S3 Touch AMOLED 2.16 стоит около 32 долларов. Есть и более дешёвые варианты на открытых чипах, примерно от 10 до 15 долларов.',
|
||||||
|
'Если у вас другая модель, под неё можно адаптировать открытую прошивку. Для простых переносов это реально сделать довольно быстро.',
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
label: 'Документация Waveshare ESP32-S3 Touch AMOLED 2.16',
|
||||||
|
href: 'https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wallet-device',
|
||||||
|
shortTitle: 'Кошелёк',
|
||||||
|
title: 'Можно ли использовать такое устройство как кошелёк?',
|
||||||
|
paragraphs: [
|
||||||
|
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
|
||||||
|
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
|
||||||
|
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getTopicById(topicId) {
|
||||||
|
return REGISTRATION_FAQ_TOPICS.find((topic) => topic.id === topicId) || REGISTRATION_FAQ_TOPICS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRegistrationFaq(navigate, topicId) {
|
||||||
|
state.registrationHelp.selectedTopic = getTopicById(topicId).id;
|
||||||
|
navigate('registration-faq-view');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const selectedTopic = getTopicById(state.registrationHelp?.selectedTopic);
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
const heroCard = document.createElement('div');
|
||||||
|
heroCard.className = 'card stack registration-faq-hero';
|
||||||
|
heroCard.innerHTML = `
|
||||||
|
<div class="badge alt">Вопросы о регистрации</div>
|
||||||
|
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const topicCard = document.createElement('div');
|
||||||
|
topicCard.className = 'card stack registration-faq-topic';
|
||||||
|
|
||||||
|
const question = document.createElement('h2');
|
||||||
|
question.className = 'registration-faq-title';
|
||||||
|
question.textContent = selectedTopic.title;
|
||||||
|
topicCard.append(question);
|
||||||
|
|
||||||
|
selectedTopic.paragraphs.forEach((paragraph) => {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'auth-copy';
|
||||||
|
p.textContent = paragraph;
|
||||||
|
topicCard.append(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(selectedTopic.links) && selectedTopic.links.length > 0) {
|
||||||
|
selectedTopic.links.forEach((linkItem) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'link-card';
|
||||||
|
link.href = linkItem.href;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.textContent = linkItem.label;
|
||||||
|
topicCard.append(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicsCard = document.createElement('div');
|
||||||
|
topicsCard.className = 'card stack';
|
||||||
|
|
||||||
|
const topicsLabel = document.createElement('p');
|
||||||
|
topicsLabel.className = 'field-label';
|
||||||
|
topicsLabel.textContent = 'Другие вопросы';
|
||||||
|
|
||||||
|
const topicsGrid = document.createElement('div');
|
||||||
|
topicsGrid.className = 'registration-faq-grid';
|
||||||
|
|
||||||
|
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = topic.shortTitle;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
state.registrationHelp.selectedTopic = topic.id;
|
||||||
|
navigate('registration-faq-view');
|
||||||
|
});
|
||||||
|
topicsGrid.append(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
topicsCard.append(topicsLabel, topicsGrid);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'auth-footer-actions';
|
||||||
|
|
||||||
|
const backButton = document.createElement('button');
|
||||||
|
backButton.className = 'ghost-btn';
|
||||||
|
backButton.type = 'button';
|
||||||
|
backButton.textContent = 'Назад';
|
||||||
|
backButton.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
|
const registerButton = document.createElement('button');
|
||||||
|
registerButton.className = 'primary-btn';
|
||||||
|
registerButton.type = 'button';
|
||||||
|
registerButton.textContent = 'К регистрации';
|
||||||
|
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
|
actions.append(backButton, registerButton);
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: 'Вопросы о регистрации',
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||||
|
}),
|
||||||
|
heroCard,
|
||||||
|
topicCard,
|
||||||
|
topicsCard,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { clearStoredMessages } from '../services/message-store.js';
|
|||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
|
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -122,8 +123,12 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
state.loginDraft.login = state.registrationDraft.login;
|
state.loginDraft.login = state.registrationDraft.login;
|
||||||
state.loginDraft.password = '';
|
state.loginDraft.password = '';
|
||||||
|
state.loginDraft.passwordMode = 'single';
|
||||||
|
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
|
||||||
state.registrationDraft.flowType = '';
|
state.registrationDraft.flowType = '';
|
||||||
state.registrationDraft.password = '';
|
state.registrationDraft.password = '';
|
||||||
|
state.registrationDraft.passwordMode = 'single';
|
||||||
|
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
|
||||||
state.registrationDraft.storagePwd = '';
|
state.registrationDraft.storagePwd = '';
|
||||||
state.registrationDraft.sessionId = '';
|
state.registrationDraft.sessionId = '';
|
||||||
state.registrationDraft.pendingKeyBundle = null;
|
state.registrationDraft.pendingKeyBundle = null;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
'register-view',
|
'register-view',
|
||||||
|
'registration-faq-view',
|
||||||
'registration-payment-view',
|
'registration-payment-view',
|
||||||
'registration-draft-keys-view',
|
'registration-draft-keys-view',
|
||||||
'registration-keys-view',
|
'registration-keys-view',
|
||||||
|
|||||||
18
shine-UI/js/services/password-words.js
Normal file
18
shine-UI/js/services/password-words.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export const PASSWORD_WORDS_COUNT = 12;
|
||||||
|
export const PASSWORD_MAX_LENGTH = 256;
|
||||||
|
|
||||||
|
export function normalizePasswordWords(wordsLike) {
|
||||||
|
const words = Array.isArray(wordsLike) ? wordsLike : [];
|
||||||
|
return Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => String(words[index] || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composePasswordFromWords(wordsLike) {
|
||||||
|
return normalizePasswordWords(wordsLike)
|
||||||
|
.map((word) => word.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyPasswordWords() {
|
||||||
|
return Array.from({ length: PASSWORD_WORDS_COUNT }, () => '');
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_SHINE_SERVER_WS,
|
DEFAULT_SHINE_SERVER_WS,
|
||||||
resolveShineServerByServerLogin,
|
resolveShineServerByServerLogin,
|
||||||
} from './services/shine-server-resolver.js';
|
} from './services/shine-server-resolver.js';
|
||||||
|
import { emptyPasswordWords } from './services/password-words.js';
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
@ -260,15 +261,22 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
flowType: '',
|
flowType: '',
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
passwordMode: 'single',
|
||||||
|
passwordWords: emptyPasswordWords(),
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
storagePwd: '',
|
storagePwd: '',
|
||||||
pendingKeyBundle: null,
|
pendingKeyBundle: null,
|
||||||
pendingSessionMaterial: null,
|
pendingSessionMaterial: null,
|
||||||
preGeneratedKeyBundle: null,
|
preGeneratedKeyBundle: null,
|
||||||
},
|
},
|
||||||
|
registrationHelp: {
|
||||||
|
selectedTopic: 'keys-storage',
|
||||||
|
},
|
||||||
loginDraft: {
|
loginDraft: {
|
||||||
login: storedSession?.login || '',
|
login: storedSession?.login || '',
|
||||||
password: '',
|
password: '',
|
||||||
|
passwordMode: 'single',
|
||||||
|
passwordWords: emptyPasswordWords(),
|
||||||
},
|
},
|
||||||
registrationPayment: {
|
registrationPayment: {
|
||||||
walletAddress: '',
|
walletAddress: '',
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
/* Глобально отключаем синюю tap-подсветку мобильных браузеров/WebView на ВСЕХ элементах
|
||||||
|
(Android/Chromium): синего квадрата при нажатии нигде быть не должно. */
|
||||||
|
* {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -401,6 +407,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-screen--lower {
|
||||||
|
align-content: start;
|
||||||
|
padding-top: clamp(80px, 18vh, 180px);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
width: 126px;
|
width: 126px;
|
||||||
height: 126px;
|
height: 126px;
|
||||||
@ -428,6 +439,22 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel--wide {
|
||||||
|
width: min(100%, 420px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-footer-actions {
|
.auth-footer-actions {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@ -1162,6 +1189,35 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-search-form-card,
|
||||||
|
.contact-search-results-card {
|
||||||
|
margin: 0 6px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(7, 10, 18, 0.88);
|
||||||
|
border: 1px solid rgba(140, 99, 255, 0.24);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-search-input {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-search-results-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(252, 234, 192, 0.92);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-search-result-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -3562,13 +3618,24 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dm-dialog-card {
|
.dm-dialog-card {
|
||||||
background: rgba(20, 25, 35, 0.4);
|
position: relative;
|
||||||
backdrop-filter: blur(25px);
|
display: grid;
|
||||||
-webkit-backdrop-filter: blur(25px);
|
grid-template-columns: 60px minmax(0, 1fr) auto;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.4);
|
gap: 12px;
|
||||||
border-radius: 20px;
|
align-items: center;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37);
|
min-height: 74px;
|
||||||
|
padding: 10px 12px 10px 10px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: rgba(7, 10, 18, 0.88);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; }
|
||||||
|
.dm-card--family { border-color: rgba(240, 184, 46, 0.42); } /* линия связи: gold (семья) */
|
||||||
|
.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */
|
||||||
|
|
||||||
.dm-screen .list-item .avatar {
|
.dm-screen .list-item .avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@ -3591,66 +3658,193 @@ textarea.input {
|
|||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-status-line {
|
/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */
|
||||||
color: rgba(255, 255, 255, 0.5);
|
/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */
|
||||||
|
.dm-screen {
|
||||||
|
--dm-tone-default: var(--rel-contact);
|
||||||
|
--dm-tone-family: var(--rel-family);
|
||||||
|
--dm-tone-shining: var(--rel-shining);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-screen .unread {
|
/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */
|
||||||
min-width: 26px;
|
.dm-head {
|
||||||
height: 26px;
|
position: sticky; top: 0; z-index: 12;
|
||||||
padding: 0 8px;
|
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px;
|
||||||
border-radius: 999px;
|
padding: 14px 14px 0;
|
||||||
|
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||||
|
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
|
||||||
|
}
|
||||||
|
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.dm-head-hex {
|
||||||
|
width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
|
||||||
|
font-weight: 700; font-size: 15px; color: #1a1205;
|
||||||
|
background: linear-gradient(150deg, #F0B82E, #D49F22);
|
||||||
|
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||||
|
box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
|
||||||
|
}
|
||||||
|
.dm-head-id { min-width: 0; display: grid; }
|
||||||
|
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; }
|
||||||
|
.dm-list-screen .dm-head-title { color: #FCEAC0; text-shadow: 0 0 6px rgba(240, 184, 46, 0.32), 0 0 14px rgba(240, 184, 46, 0.12); }
|
||||||
|
/* Центр шапки — светящийся бренд «Shine» */
|
||||||
|
.dm-head-shine {
|
||||||
|
font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0;
|
||||||
|
text-shadow: 0 0 6px rgba(240, 184, 46, 0.55), 0 0 16px rgba(240, 184, 46, 0.38), 0 0 30px rgba(240, 184, 46, 0.20);
|
||||||
|
animation: dm-shine-pulse 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes dm-shine-pulse {
|
||||||
|
0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); }
|
||||||
|
50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } }
|
||||||
|
.dm-head-plus {
|
||||||
|
justify-self: end; width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300;
|
||||||
|
color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6);
|
||||||
|
background: rgba(12, 12, 16, 0.66);
|
||||||
|
box-shadow: 0 0 20px rgba(240, 184, 46, 0.32), 0 0 6px rgba(240, 184, 46, 0.28), inset 0 0 12px rgba(240, 184, 46, 0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; }
|
||||||
|
.dm-divider::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); }
|
||||||
|
.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
|
||||||
|
|
||||||
|
/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
|
||||||
|
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 6px; padding-bottom: calc(86px + 16px); }
|
||||||
|
|
||||||
|
/* текст карточки */
|
||||||
|
.dm-row-main { min-width: 0; }
|
||||||
|
.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
.dm-row-titlewrap { flex-wrap: wrap; row-gap: 6px; }
|
||||||
|
.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-contact-note {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
min-height: 22px;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.5);
|
padding: 0 8px;
|
||||||
background: rgba(212, 175, 55, 0.22);
|
border-radius: 999px;
|
||||||
color: rgba(255, 200, 50, 0.95);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(244, 246, 255, 0.62);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
|
||||||
|
.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
|
||||||
|
.dm-name-check svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
|
||||||
|
.dm-av { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; position: relative; }
|
||||||
|
.dm-av .avatar { width: 50px; height: 50px; min-width: 50px; min-height: 50px; border: none; box-shadow: none; }
|
||||||
|
/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
|
||||||
|
.dm-av--default { box-shadow: none; }
|
||||||
|
.dm-av--family { box-shadow: none; }
|
||||||
|
/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim,
|
||||||
|
тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow — пульс box-shadow,
|
||||||
|
fg-shine-halo — дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем
|
||||||
|
второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на
|
||||||
|
стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная — не размывает текст/соседей. */
|
||||||
|
.dm-av--shining {
|
||||||
|
border: 1px solid rgba(150, 240, 255, 0.62);
|
||||||
|
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dm-av--shining::before {
|
||||||
|
content: ''; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none;
|
||||||
|
background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
|
||||||
|
filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */
|
||||||
|
animation: fg-shine-halo 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dm-av--shining { animation: none; }
|
||||||
|
.dm-av--shining::before { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-row-meta-col {
|
.dm-row-meta-col {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
justify-items: end;
|
|
||||||
align-content: end;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 64px;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-main {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
align-self: stretch;
|
||||||
grid-template-rows: auto auto;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
.dm-row-meta-line {
|
||||||
.dm-row-title-wrap {
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
.dm-row-meta-spacer {
|
||||||
.dm-row-title {
|
width: 24px;
|
||||||
overflow: hidden;
|
height: 24px;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-row-last-message {
|
|
||||||
margin-top: 0 !important;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-time {
|
.dm-row-time {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
color: rgba(244, 246, 255, 0.44);
|
||||||
}
|
}
|
||||||
|
.dm-row-time--empty {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
|
||||||
|
.dm-unread-badge {
|
||||||
|
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text);
|
||||||
|
background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55);
|
||||||
|
}
|
||||||
|
.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
|
||||||
|
.dm-chevron svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.dm-dialog-card {
|
||||||
|
grid-template-columns: 60px minmax(0, 1fr);
|
||||||
|
row-gap: 10px;
|
||||||
|
}
|
||||||
|
.dm-row-meta-col {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
|
||||||
|
.dm-via {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||||
|
width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer;
|
||||||
|
color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08);
|
||||||
|
}
|
||||||
|
.dm-via-icon { display: inline-flex; }
|
||||||
|
.dm-via-icon svg { width: 14px; height: 14px; }
|
||||||
|
/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */
|
||||||
|
.dm-via-path {
|
||||||
|
display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6;
|
||||||
|
flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px;
|
||||||
|
background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.dm-via-path.is-open { display: flex; }
|
||||||
|
.dm-via-node {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text); font-size: 12px; cursor: default;
|
||||||
|
}
|
||||||
|
button.dm-via-node { cursor: pointer; }
|
||||||
|
button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
|
||||||
|
.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; }
|
||||||
|
.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; }
|
||||||
|
.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; }
|
||||||
|
.dm-via-node-name { white-space: nowrap; }
|
||||||
|
.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); }
|
||||||
|
|
||||||
|
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
|
||||||
|
даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
|
||||||
|
html, body { overflow-x: hidden; }
|
||||||
|
|
||||||
.dm-chat-wrap {
|
.dm-chat-wrap {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -3801,9 +3995,14 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dm-message-actions-menu {
|
.dm-message-actions-menu {
|
||||||
width: min(52vw, 240px);
|
width: min(72vw, 220px);
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||||
|
background: rgba(10, 12, 18, 0.96);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
-webkit-backdrop-filter: blur(22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-floating-menu-layer {
|
.dm-floating-menu-layer {
|
||||||
@ -3832,7 +4031,10 @@ textarea.input {
|
|||||||
|
|
||||||
.dm-message-action-btn {
|
.dm-message-action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-message-action-btn--danger {
|
.dm-message-action-btn--danger {
|
||||||
@ -3851,34 +4053,6 @@ textarea.input {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DM messages-list status + empty block as full glass buttons */
|
|
||||||
.dm-screen .dm-status-line {
|
|
||||||
display: block;
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
margin: 2px 20px 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(18, 24, 38, 0.42);
|
|
||||||
backdrop-filter: blur(25px);
|
|
||||||
-webkit-backdrop-filter: blur(25px);
|
|
||||||
border: 1px solid rgba(212, 175, 55, 0.32);
|
|
||||||
color: rgba(255, 227, 154, 0.92);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide "Нет диалогов." line on DM list per UI request */
|
|
||||||
.dm-screen .dm-status-line {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-status-line.is-available {
|
|
||||||
color: rgba(255, 227, 154, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-status-line.is-unavailable {
|
|
||||||
color: rgba(255, 161, 176, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-list > .card.meta-muted {
|
.dm-screen .dm-list > .card.meta-muted {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
@ -4008,6 +4182,151 @@ textarea.input {
|
|||||||
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
|
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.registration-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(132, 162, 228, 0.22);
|
||||||
|
background: rgba(20, 31, 52, 0.72);
|
||||||
|
color: #eef3ff;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-toggle input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #d4af37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-words-block[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-words-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-words-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-word-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-word-number {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b2c2e6;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-word-input {
|
||||||
|
min-height: 44px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-card {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-grid .ghost-btn,
|
||||||
|
.registration-faq-grid .secondary-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.registration-words-grid,
|
||||||
|
.registration-faq-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.registration-words-grid,
|
||||||
|
.registration-faq-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-hero {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-topic {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-faq-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #f6deb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid rgba(180, 180, 180, 0.5);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: rgba(80, 160, 255, 0.9);
|
||||||
|
transition: width 180ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
|
||||||
|
.toolbar-icon-img {
|
||||||
|
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
|
||||||
|
width: var(--tab-icon-size);
|
||||||
|
height: var(--tab-icon-size);
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
transition: transform .12s ease, filter .15s ease;
|
||||||
|
}
|
||||||
|
/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */
|
||||||
|
.toolbar-btn.active .toolbar-icon-img {
|
||||||
|
filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08);
|
||||||
|
}
|
||||||
|
/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */
|
||||||
|
.toolbar-btn:active .toolbar-icon-img {
|
||||||
|
transform: scale(0.9);
|
||||||
|
filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2);
|
||||||
|
}
|
||||||
|
/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */
|
||||||
|
.toolbar-btn-hero .toolbar-icon-img {
|
||||||
|
/* Крупнее ВИЗУАЛЬНО через transform (origin center) — раскладочный размер как у остальных (27px),
|
||||||
|
поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */
|
||||||
|
transform: scale(1.63); /* ≈44px при базовых 27px */
|
||||||
|
filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */
|
||||||
|
}
|
||||||
|
.toolbar-btn-hero.active .toolbar-icon-img {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
.toolbar-btn-hero:active .toolbar-icon-img {
|
||||||
|
transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */
|
||||||
|
filter: brightness(1.25); /* нажатие — только подсветление, без ореола */
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-channels-hold-overlay {
|
.toolbar-channels-hold-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
@ -4424,10 +4743,17 @@ textarea.input {
|
|||||||
.toolbar-btn-network {
|
.toolbar-btn-network {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
-webkit-tap-highlight-color: transparent; /* нет синей вспышки-квадрата при тапе (Android WebView/Chromium) */
|
||||||
|
}
|
||||||
|
/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */
|
||||||
|
.toolbar-btn-network:focus,
|
||||||
|
.toolbar-btn-network:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn-network::before {
|
.toolbar-btn-network::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 6px;
|
inset: 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@ -4,6 +4,20 @@
|
|||||||
Отдельный модуль, чтобы не раздувать components.css.
|
Отдельный модуль, чтобы не раздувать components.css.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Канонические токены ЯЗЫКА СВЯЗЕЙ (единый источник цвета отношений для всего продукта).
|
||||||
|
Экран «Личные сообщения» наследует их через --dm-* (не дублирует hex).
|
||||||
|
(force-graph пока использует свои JS-цвета RELATION_COLORS — миграция на токены = будущая задача.) */
|
||||||
|
:root {
|
||||||
|
--rel-contact: #8C63FF; /* violet — обычная связь / контакт */
|
||||||
|
--rel-family: #F0B82E; /* gold — семья / близкий круг / важность / подтверждение */
|
||||||
|
--rel-shining: #68D8FF; /* celestial — сияющий / сильная активная связь */
|
||||||
|
--rel-link: #19E58A; /* emerald — статус «Связь» (активный канал) */
|
||||||
|
--rel-contact-glow: rgba(140, 99, 255, 0.24);
|
||||||
|
--rel-family-glow: rgba(240, 184, 46, 0.30);
|
||||||
|
--rel-shining-glow: rgba(104, 216, 255, 0.35);
|
||||||
|
--rel-link-glow: rgba(25, 229, 138, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
.fg-stage {
|
.fg-stage {
|
||||||
touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */
|
touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@ -120,14 +134,73 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */
|
/* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */
|
||||||
|
/* 58px → радиус 29 = ORB_R в force-graph.js (контакт линий берётся от этого радиуса). */
|
||||||
.fg-node .node-dot {
|
.fg-node .node-dot {
|
||||||
width: 52px;
|
width: 58px;
|
||||||
height: 52px;
|
height: 58px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SVG-«стеклянный орб» — масштабируем так, чтобы сфера (r42 = 84% SVG) ≈ диаметр узла → линии-связи
|
||||||
|
прилипают к краю орба, как раньше. Хост .node-dot держит размер/состояния/синхронизацию позиций. */
|
||||||
|
.fg-node .node-dot.fg-orb-host {
|
||||||
|
position: relative;
|
||||||
|
background: none;
|
||||||
|
overflow: visible; /* не срезать внешнее свечение орба */
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* A/B PNG-оверлей орба (ветка glass-png-overlay): фото снизу + запечённый стеклянный PNG сверху.
|
||||||
|
Бокс = 119% от .node-dot (как .fg-orb-svg → сфера ≈ кромке node-dot, контакт линий от ORB_R сохраняется). */
|
||||||
|
/* Специфичность `.fg-orb-host …` бьёт глобальное `.node-dot img` (иначе оно гасит opacity→0
|
||||||
|
и форсит размер 100%). Поэтому opacity/размеры/радиус задаём здесь явно. */
|
||||||
|
/* Кромку даёт сам glass_overlay.png → убираем остаточный border .node-dot (синее кольцо
|
||||||
|
старого векторного орба). Только у PNG-хоста; вектор и свечение/box-shadow не трогаем. */
|
||||||
|
/* (0,4,0) — выше специфичности правил категории `.fg-node.is-family .node-dot` (0,3,0),
|
||||||
|
иначе их background/border перебивают. */
|
||||||
|
.fg-node .node-dot.fg-orb-host:has(.fg-pngorb) {
|
||||||
|
border: none;
|
||||||
|
background: none; /* фон-градиент категории не торчит из-под прозрачного стекла (та самая «обводка») */
|
||||||
|
}
|
||||||
|
.fg-orb-host .fg-pngorb {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 119%; height: 119%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.fg-orb-host .fg-pngorb-glass {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fg-orb-host .fg-pngorb-photo {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 78%; height: 78%; /* ~78% от бокса оверлея — сидит внутри стеклянной кромки */
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 50%; /* фолбэк, если mask не поддержан */
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
/* Мягкий край: фото непрозрачно до --feather-full, плавно гаснет к 0 у --feather-edge —
|
||||||
|
сливается со стеклом без жёсткого ободка. Силу растушёвки крутим этими двумя параметрами. */
|
||||||
|
--feather-full: 62%;
|
||||||
|
--feather-edge: 78%;
|
||||||
|
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge));
|
||||||
|
mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge));
|
||||||
|
}
|
||||||
|
.fg-orb-host .fg-pngorb-init {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
background: linear-gradient(165deg, #785038, #5f3e2c);
|
background: linear-gradient(165deg, #785038, #5f3e2c);
|
||||||
border-color: rgba(255, 194, 143, 0.6);
|
border-color: rgba(255, 194, 143, 0.6);
|
||||||
@ -334,14 +407,6 @@
|
|||||||
.fg-dot.is-tier3 { animation: none; }
|
.fg-dot.is-tier3 { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
|
|
||||||
.fg-deep-chip.is-active {
|
|
||||||
background: rgba(150, 130, 255, 0.18);
|
|
||||||
border-color: rgba(190, 170, 255, 0.6);
|
|
||||||
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3);
|
|
||||||
color: #efeaff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* «Призрак» старой карты при Z-переходе (эффект погружения) */
|
/* «Призрак» старой карты при Z-переходе (эффект погружения) */
|
||||||
.fg-ghost-layer {
|
.fg-ghost-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -567,12 +632,8 @@
|
|||||||
}
|
}
|
||||||
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
|
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
|
||||||
|
|
||||||
/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */
|
/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
|
||||||
.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); }
|
box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */
|
||||||
.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); }
|
|
||||||
.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); }
|
|
||||||
.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); }
|
|
||||||
/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */
|
|
||||||
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
|
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
|
||||||
|
|
||||||
/* Строка поиска (оверлей вверху, под панелью фильтров) */
|
/* Строка поиска (оверлей вверху, под панелью фильтров) */
|
||||||
@ -659,20 +720,8 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */
|
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */
|
||||||
.fg-node.is-common .node-dot {
|
.fg-node.is-common .node-dot {
|
||||||
border-color: rgba(255, 214, 120, 0.95);
|
border-color: rgba(255, 214, 120, 0.95);
|
||||||
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
|
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
|
||||||
}
|
}
|
||||||
.fg-node.is-common .node-dot::after {
|
|
||||||
content: '★';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -5px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1;
|
|
||||||
color: #ffd678;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user