Задание которое надо доделать
This commit is contained in:
commit
c25393e3b6
@ -1,73 +0,0 @@
|
||||
# Dev_Docs — оглавление
|
||||
|
||||
Этот набор документов сделан по текущему состоянию кода сервера (`/workspace/SHiNE-server`) и разбит по темам.
|
||||
|
||||
## Список документов
|
||||
|
||||
0. **API/00_Common_API_Format.md**
|
||||
Общий формат JSON-запросов и JSON-ответов по всему API: `op`, `requestId`, `status`, `ok`, `payload`, единые правила успеха и ошибок.
|
||||
|
||||
0. **API/01_User_Registration_API.md**
|
||||
Временная глава API по регистрации пользователя: `AddUser` и временный `GetUser`, с пометкой о будущем переходе проверки identity напрямую через Solana.
|
||||
|
||||
0. **API/02_Authentication_API.md**
|
||||
Глава API по авторизации: `AuthChallenge`, `CreateAuthSession`, `SessionChallenge`, `SessionLogin`, подписи, `deviceKey`, `sessionKey`.
|
||||
|
||||
0. **API/03_Session_Management_API.md**
|
||||
Глава API по управлению сессиями: `ListSessions` и `CloseActiveSession`.
|
||||
|
||||
|
||||
0. **API/04_Add_Block_to_Blockchain_API.md**
|
||||
Глава API по записи блоков: формат `AddBlock`, коды ошибок, поддержанные типы/подтипы блоков и рекомендации по ресинхронизации.
|
||||
|
||||
1. **01_Connection_and_Sessions.md**
|
||||
0. **API/05_Technical_Requests_API.md**
|
||||
Технические запросы без авторизации: `Ping` для keep-alive и `GetServerInfo` для проверки доступности узла и чтения его публичной информации.
|
||||
|
||||
1. **01_Connection_and_Sessions.md**
|
||||
Процесс подключения к WebSocket, авторизация (двухшаговая), создание сессии, вход в существующую сессию, просмотр и закрытие сессий.
|
||||
|
||||
2. **02_Blockchain_Structure_and_Block_Types.md**
|
||||
Архитектура блокчейна, форматы и типы блоков, что уже можно делать каждым типом блока.
|
||||
|
||||
3. **03_Addable_Blocks_Channels_Messages_Connections.md**
|
||||
Какие блоки добавляются через `AddBlock`, как делать каналы/подписки/контакты/друзей/лайки/ответы, что уже есть и чего не хватает в API.
|
||||
|
||||
4. **04_Query_Design_for_Subscriptions_Counters_and_Sync.md**
|
||||
Проектирование новых API-запросов: список подписок с общим/новым числом сообщений, список сообщений канала, граф ответов для сообщения, поток синхронизации online/offline.
|
||||
|
||||
5. **05_Open_Questions_and_TODO.md**
|
||||
Список открытых вопросов, рисков и приоритетов для доработки сервера.
|
||||
|
||||
## Почему так разбито
|
||||
|
||||
- **Сначала протокол и сессии** — это входная точка клиента.
|
||||
- **Потом блокчейн-слой** — какие данные вообще можно выразить блоками.
|
||||
- **Потом прикладные функции (каналы/сообщения/связи)** — что реально можно сделать уже сейчас.
|
||||
- **Потом проектирование отсутствующих запросов** — чтобы закрыть разрыв между текущим сервером и нужной функциональностью клиента.
|
||||
- **В конце вопросы** — чтобы быстро согласовать спорные места.
|
||||
|
||||
|
||||
6. **Blockchain/00_Blockchain_Formats_and_Block_Types.md**
|
||||
Индекс раздела по форматам блокчейна и блоков.
|
||||
|
||||
7. **Blockchain/01_Common_Block_Format.md**
|
||||
Общий бинарный формат блока (Frame v0), подпись и обязательные проверки на `AddBlock`.
|
||||
|
||||
8. **Blockchain/02_Blockchain_Kinds_and_Lines.md**
|
||||
Виды блокчейнов (`login-NNN`) и логические линии внутри цепочки.
|
||||
|
||||
9. **Blockchain/10_TECH_Blocks.md**
|
||||
Системные блоки: `HEADER_COMPAT`, `TECH_CREATE_CHANNEL`.
|
||||
|
||||
10. **Blockchain/11_TEXT_Blocks.md**
|
||||
Текстовые блоки: `POST`, `EDIT_POST`, `REPLY`, `EDIT_REPLY`.
|
||||
|
||||
11. **Blockchain/12_REACTION_Blocks.md**
|
||||
Реакции: `REACTION_LIKE`.
|
||||
|
||||
12. **Blockchain/13_CONNECTION_Blocks.md**
|
||||
Связи и подписки: `FRIEND/CONTACT/FOLLOW` и обратные операции.
|
||||
|
||||
13. **Blockchain/14_USER_PARAM_Blocks.md**
|
||||
Пользовательские параметры `key/value`.
|
||||
@ -1,65 +0,0 @@
|
||||
# Соединение с сервером и сессии
|
||||
|
||||
## 1) Базовый транспорт
|
||||
|
||||
- Сервер работает по WebSocket + JSON-протокол (`op`, `requestId`, `payload`).
|
||||
- Для каждой операции есть handler в `JsonHandlerRegistry`.
|
||||
|
||||
## 2) Схема авторизации (актуальная)
|
||||
|
||||
В проекте реализованы **две двухшаговые схемы**:
|
||||
|
||||
### A. Создание новой сессии (через device key)
|
||||
|
||||
1. `AuthChallenge(login)`
|
||||
- сервер проверяет, что пользователь существует;
|
||||
- кладёт в контекст соединения `authNonce`.
|
||||
|
||||
2. `CreateAuthSession(...)`
|
||||
- клиент подписывает строку `AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}` приватным **device key**;
|
||||
- сервер валидирует подпись, создаёт запись в `active_sessions`, возвращает `sessionId`;
|
||||
- в сессии хранится `session_key` (публичный ключ сессии), который клиент сгенерировал для дальнейших логинов.
|
||||
|
||||
### B. Вход в существующую сессию (через session key)
|
||||
|
||||
1. `SessionChallenge(sessionId)`
|
||||
- сервер выдаёт одноразовый nonce с TTL.
|
||||
|
||||
2. `SessionLogin(sessionId, timeMs, signature)`
|
||||
- клиент подписывает `SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}` приватным ключом сессии;
|
||||
- сервер проверяет подпись по `active_sessions.session_key`;
|
||||
- при успехе возвращает `storagePwd`.
|
||||
|
||||
## 3) Работа со списком сессий
|
||||
|
||||
### `ListSessions`
|
||||
|
||||
- Доступно только в состоянии `AUTH_STATUS_USER`.
|
||||
- Возвращает все активные сессии текущего пользователя:
|
||||
- `sessionId`
|
||||
- информация о клиенте
|
||||
- `lastAuthirificatedAtMs`
|
||||
- гео (по ip, через кэш/lookup).
|
||||
|
||||
### `CloseActiveSession`
|
||||
|
||||
- В реестре операций присутствует.
|
||||
- Используется для закрытия указанной или текущей сессии.
|
||||
|
||||
## 4) Важные детали безопасности
|
||||
|
||||
- Есть проверка рассинхрона времени клиента (обычно ±30 секунд).
|
||||
- Nonce одноразовые, хранятся в контексте конкретного ws-соединения.
|
||||
- При ошибках подписи/контекста сервер может закрывать WebSocket.
|
||||
|
||||
## 5) Что уже хорошо
|
||||
|
||||
- Разделены key-и: `device key` (создание сессии) и `session key` (повторные входы).
|
||||
- Не передаётся приватный ключ — только подписи.
|
||||
- Авторизация привязана к challenge/nonce.
|
||||
|
||||
## 6) Что стоит дополнительно улучшить
|
||||
|
||||
1. Добавить централизованный лимит попыток на `SessionLogin`/`CreateAuthSession`.
|
||||
2. Логировать причины отказов в метриках (без утечки чувствительных данных).
|
||||
3. Явно документировать формат Base64 (URL-safe/standard) для каждого поля, чтобы клиенты не путались.
|
||||
@ -1,106 +0,0 @@
|
||||
# Структура блокчейна и типы блоков
|
||||
|
||||
## 1) Общая модель
|
||||
|
||||
Каждый блок содержит:
|
||||
|
||||
- заголовок (тип, subtype, version, blockNumber и пр.);
|
||||
- `bodyBytes` (полезная нагрузка конкретного типа);
|
||||
- хэш и подпись.
|
||||
|
||||
На сервере при `AddBlock` выполняется:
|
||||
|
||||
1. Проверка формата блока.
|
||||
2. `body.check()`.
|
||||
3. Проверка непрерывности цепочки (`blockNumber == last + 1`, `prevHash`).
|
||||
4. Проверка подписи блока ключом блокчейна.
|
||||
5. Сохранение в БД + файл блокчейна.
|
||||
|
||||
## 2) Типы блоков (msg_type)
|
||||
|
||||
## type=0 (TECH / HEADER)
|
||||
|
||||
- `HEADER_COMPAT (subType=0)` — стартовый служебный блок.
|
||||
- `TECH_CREATE_CHANNEL (subType=1)` — создание канала (линии) с именем канала.
|
||||
|
||||
Что можно делать:
|
||||
- инициализировать blockchain;
|
||||
- заводить новые каналы внутри blockchain.
|
||||
|
||||
## type=1 (TEXT)
|
||||
|
||||
- `TEXT_POST (10)` — публикация в линии канала.
|
||||
- `TEXT_EDIT_POST (11)` — редактирование поста (target на оригинал).
|
||||
- `TEXT_REPLY (20)` — reply на любой target (может быть чужой блокчейн).
|
||||
- `TEXT_EDIT_REPLY (21)` — редактирование reply.
|
||||
|
||||
Что можно делать:
|
||||
- публиковать посты в каналы;
|
||||
- делать древовидные ответы;
|
||||
- редактировать ранее отправленные тексты.
|
||||
|
||||
## type=2 (REACTION)
|
||||
|
||||
- `REACTION_LIKE (1)` — лайк на target-блок.
|
||||
|
||||
Что можно делать:
|
||||
- ставить лайки на сообщение.
|
||||
|
||||
## type=3 (CONNECTION)
|
||||
|
||||
- `FRIEND / UNFRIEND`
|
||||
- `CONTACT / UNCONTACT`
|
||||
- `FOLLOW / UNFOLLOW`
|
||||
|
||||
Что можно делать:
|
||||
- дружба, контакты, подписки.
|
||||
|
||||
## type=4 (USER_PARAM)
|
||||
|
||||
- `USER_PARAM_TEXT_TEXT (1)` — произвольный `key/value` параметр пользователя.
|
||||
|
||||
Что можно делать:
|
||||
- хранить пользовательские технические настройки и состояние синхронизации.
|
||||
|
||||
## 3) Линии (line model)
|
||||
|
||||
Для части блоков есть line-поля:
|
||||
- `line_code`
|
||||
- `prev_line_number`
|
||||
- `prev_line_hash`
|
||||
- `this_line_number`
|
||||
|
||||
Смысл:
|
||||
- отдельные последовательности для каналов/связей/параметров;
|
||||
- БД-триггеры проверяют целостность ссылок line->prev.
|
||||
|
||||
## 4) Target model
|
||||
|
||||
Для reply/like/connection/edit используются target-поля:
|
||||
- `to_bch_name`
|
||||
- `to_block_number`
|
||||
- `to_block_hash`
|
||||
- `to_login` (сервер может вычислять из blockchainName).
|
||||
|
||||
Это позволяет строить граф:
|
||||
- кто на кого подписался;
|
||||
- кто кому ответил;
|
||||
- кто какой блок лайкнул;
|
||||
- какой блок является edit какого.
|
||||
|
||||
## 5) Сильные стороны текущей структуры
|
||||
|
||||
- Достаточно выразительная модель для соц-сценариев (каналы/ответы/лайки/связи).
|
||||
- Есть счетчики `message_stats` (likes/replies/edits) на уровне БД.
|
||||
- Есть `connections_state` как «текущее состояние», собранное триггерами.
|
||||
|
||||
## 6) Статус унификации subType
|
||||
|
||||
Схема `subType` должна быть единой во всех модулях проекта:
|
||||
|
||||
- `TEXT_POST = 10`
|
||||
- `TEXT_EDIT_POST = 11`
|
||||
- `TEXT_REPLY = 20`
|
||||
- `TEXT_EDIT_REPLY = 21`
|
||||
|
||||
Это правило должно одинаково использоваться в `shine-server-blockchain`, `shine-server-db`, SQL-триггерах и DAO.
|
||||
@ -1,75 +0,0 @@
|
||||
# Какие блоки можно добавлять и что уже есть по API
|
||||
|
||||
## 1) Что реально доступно по сети сейчас
|
||||
|
||||
В `JsonHandlerRegistry` сейчас есть:
|
||||
- `AddBlock`
|
||||
- `GetFriendsLists`
|
||||
- `UpsertUserParam`, `GetUserParam`, `ListUserParams`
|
||||
- auth/system и тестовые операции.
|
||||
|
||||
Отдельных read-API для каналов/сообщений пока нет (только запись блока + частично списки друзей + user params).
|
||||
|
||||
## 2) Как добавляются блоки
|
||||
|
||||
Через `AddBlock` клиент отправляет:
|
||||
- blockchainName
|
||||
- blockNumber
|
||||
- prevBlockHash
|
||||
- blockBytesB64
|
||||
|
||||
Сервер:
|
||||
- парсит block;
|
||||
- проверяет подпись блокчейна;
|
||||
- извлекает `line`/`target` из body;
|
||||
- сохраняет в `blocks`;
|
||||
- обновляет `blockchain_state`;
|
||||
- триггеры автоматически обновляют производные таблицы (`connections_state`, `message_stats`).
|
||||
|
||||
## 3) Конкретные сценарии
|
||||
|
||||
### 3.1 Создать канал
|
||||
|
||||
Добавить TECH-блок `CreateChannelBody` (type=0/subType=TECH_CREATE_CHANNEL).
|
||||
|
||||
### 3.2 Опубликовать сообщение в своём канале
|
||||
|
||||
Добавить `TEXT_POST` в нужную линию канала (`lineCode` указывает на root канала).
|
||||
|
||||
### 3.3 Ответ на сообщение
|
||||
|
||||
Добавить `TEXT_REPLY` с target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`).
|
||||
|
||||
### 3.4 Лайк
|
||||
|
||||
Добавить `REACTION_LIKE` с target сообщения.
|
||||
|
||||
### 3.5 Подписаться / отписаться от канала
|
||||
|
||||
Добавить `CONNECTION_FOLLOW` / `CONNECTION_UNFOLLOW` с target root канала.
|
||||
|
||||
### 3.6 Добавить в контакты/друзья
|
||||
|
||||
- Контакты: `CONNECTION_CONTACT` / `CONNECTION_UNCONTACT`.
|
||||
- Друзья: `CONNECTION_FRIEND` / `CONNECTION_UNFRIEND`.
|
||||
|
||||
## 4) Есть ли метод «подписаться на канал» кроме публикации?
|
||||
|
||||
Да: подписка — это **не отдельный RPC**, а блок `CONNECTION_FOLLOW`, отправляемый через `AddBlock`.
|
||||
|
||||
## 5) Есть ли методы для сохранения технического состояния (например, сколько прочитано)
|
||||
|
||||
Да, есть `UpsertUserParam`.
|
||||
|
||||
Практично хранить:
|
||||
- `channel_last_read:<bchName>:<lineCode> -> <lastSeenMessageSeq или blockNumber>`
|
||||
- `channel_last_sync_at -> <time_ms>`
|
||||
|
||||
Ограничение: `users_params` сейчас хранит строки `param/value`, то есть сложные структуры лучше класть в JSON-строку с версией схемы.
|
||||
|
||||
## 6) Чего не хватает прямо сейчас
|
||||
|
||||
1. Нет RPC, который вернёт **список подписанных каналов** и счётчики сообщений.
|
||||
2. Нет RPC, который вернёт **сообщения конкретного канала** (с пагинацией).
|
||||
3. Нет RPC, который вернёт **подробный тред сообщения** (предки + все ответы) одним JSON.
|
||||
4. Нет RPC «ленты событий» (кто лайкнул/ответил/подписался) как отдельного серверного канала.
|
||||
@ -1,137 +0,0 @@
|
||||
# Проектирование запросов: подписки, счетчики, синхронизация, сообщения
|
||||
|
||||
## 1) Цель
|
||||
|
||||
Сделать один запрос, который отдаёт:
|
||||
- все каналы пользователя (подписки + опционально self-каналы);
|
||||
- сколько сообщений всего в каждом канале;
|
||||
- сколько новых (с учётом last_read).
|
||||
|
||||
И отдельно:
|
||||
- запрос сообщений канала;
|
||||
- запрос треда конкретного сообщения (предки + все потомки + stats).
|
||||
|
||||
## 2) Предлагаемые API
|
||||
|
||||
## A. `ListMyChannelsWithCounters`
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "ListMyChannelsWithCounters",
|
||||
"requestId": "...",
|
||||
"includeSelf": true,
|
||||
"includePrivate": true
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"op": "ListMyChannelsWithCounters",
|
||||
"requestId": "...",
|
||||
"status": 200,
|
||||
"channels": [
|
||||
{
|
||||
"channelId": "targetBch:targetRootBlock",
|
||||
"channelType": "personal|public|system_notifications|dm",
|
||||
"ownerLogin": "Alice",
|
||||
"bchName": "Alice-001",
|
||||
"lineCode": 0,
|
||||
"title": "Alice main",
|
||||
"totalMessages": 1234,
|
||||
"lastReadSeq": 1200,
|
||||
"newMessages": 34,
|
||||
"lastMessage": {
|
||||
"blockNumber": 3500,
|
||||
"timeMs": 1760000000000,
|
||||
"preview": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## B. `GetChannelMessages`
|
||||
|
||||
- Возвращает сообщения канала по `channelId` или `(bchName,lineCode)`.
|
||||
- Нужны `limit`, `before/after`, сортировка и флаг `includeStats`.
|
||||
|
||||
## C. `GetMessageThreadGraph`
|
||||
|
||||
Один JSON для:
|
||||
- целевого сообщения;
|
||||
- всей цепочки родителей до корня;
|
||||
- всех ответов (даже 100+);
|
||||
- stats по каждому узлу (`likes/replies/edits`).
|
||||
|
||||
## 3) Как считать total/new
|
||||
|
||||
## Источник truth по подпискам
|
||||
|
||||
Использовать `connections_state` где `rel_type=FOLLOW`.
|
||||
|
||||
## Источник сообщений канала
|
||||
|
||||
`blocks` где `msg_type=TEXT` и блок принадлежит линии канала (`line_code`).
|
||||
|
||||
## Источник last_read
|
||||
|
||||
`users_params` ключ вида `read.<channelId>` = число (last seen seq или blockNumber).
|
||||
|
||||
`newMessages = max(0, totalMessages - lastReadSeq)` (или по blockNumber/seq в выбранной модели).
|
||||
|
||||
## 4) Offline/online синхронизация
|
||||
|
||||
Рекомендуемый поток:
|
||||
|
||||
1. Клиент локально хранит last_read/last_sync.
|
||||
2. При подключении делает:
|
||||
- `ListMyChannelsWithCounters`
|
||||
- затем по каналам с `newMessages > 0` делает `GetChannelMessages`.
|
||||
3. После отображения сообщений обновляет сервер через `UpsertUserParam` (или новый bulk-метод).
|
||||
4. Сервер всегда остаётся source of truth для chain/подписок; клиент — кэш.
|
||||
|
||||
## 5) Подписи и device key
|
||||
|
||||
- Операции изменения данных пользователя (`UpsertUserParam`) уже подписываются device key.
|
||||
- Для нового bulk-обновления read-состояния лучше сохранить тот же принцип: подписанный payload от device key.
|
||||
|
||||
## 6) Разделение каналов: личные / обычные / системные
|
||||
|
||||
Предложение:
|
||||
|
||||
- `personal_main`: root = HEADER (`lineCode=0`) пользователя.
|
||||
- `public_channel`: root = CREATE_CHANNEL (lineCode = blockNumber root).
|
||||
- `dm_channel`: отдельный тип канала (пока не реализован; либо как специальный connection+line policy).
|
||||
- `system_notifications`: виртуальный/материализованный канал событий (ответы, лайки, follow/unfollow/friend/contact).
|
||||
|
||||
## 7) Канал уведомлений (ответили/лайкнули/подписались)
|
||||
|
||||
Сейчас это не готово как отдельная сущность API.
|
||||
|
||||
Два варианта:
|
||||
|
||||
1. **Виртуальный канал (рекомендуется сначала)**
|
||||
- не писать новые блоки;
|
||||
- собирать события SQL-запросом из `blocks` + `message_stats` + `connections_state`.
|
||||
|
||||
2. **Материализованный канал**
|
||||
- триггеры/воркер пишут отдельные event-записи в таблицу уведомлений;
|
||||
- быстрее чтение, но сложнее консистентность.
|
||||
|
||||
## 8) Что уже частично готово
|
||||
|
||||
- База уже считает `likes/replies/edits` через `message_stats` триггеры.
|
||||
- База уже держит текущее состояние связей через `connections_state`.
|
||||
|
||||
## 9) Что нужно дописать в сервере
|
||||
|
||||
1. Новые handlers в `JsonHandlerRegistry`:
|
||||
- `ListMyChannelsWithCounters`
|
||||
- `GetChannelMessages`
|
||||
- `GetMessageThreadGraph`
|
||||
- `ListNotificationFeed`.
|
||||
2. Новые DAO + SQL-представления/CTE.
|
||||
3. Унификация subType-констант (иначе статистика и выборки будут «плыть»).
|
||||
4. Пагинация и лимиты ответа (для больших тредов).
|
||||
@ -1,36 +0,0 @@
|
||||
# Открытые вопросы и TODO для согласования
|
||||
|
||||
## Критичные вопросы
|
||||
|
||||
1. **Что считаем “сообщением канала” для counters**
|
||||
- Только `TEXT_POST`?
|
||||
- Или ещё `TEXT_EDIT_POST` и/или `REPLY` в этом же line?
|
||||
|
||||
2. **Что такое “прочитано”**
|
||||
- По `this_line_number`?
|
||||
- По `block_number`?
|
||||
- По времени?
|
||||
|
||||
3. **Личные и публичные каналы**
|
||||
- Явно вводим `channelType` в API?
|
||||
- Нужны ли отдельные private/dm каналы в MVP?
|
||||
|
||||
4. **Уведомления (лайк/reply/follow/friend)**
|
||||
- Делаем сначала виртуальный канал (query-time), потом материализацию?
|
||||
|
||||
## Технический TODO (рекомендуемый порядок)
|
||||
|
||||
1. Добавить DAO для выборки каналов с counters.
|
||||
2. Добавить read-api handlers (3-4 операции, описанные в 04 документе).
|
||||
3. Добавить integration tests:
|
||||
- подписка -> counters;
|
||||
- read progress -> newMessages;
|
||||
- thread graph на 100+ ответов.
|
||||
4. Добавить индекс(ы) под новые query-паттерны (по `line_code`, `to_*`, `msg_type/subtype`).
|
||||
|
||||
## Дополнительные идеи
|
||||
|
||||
- Для `GetMessageThreadGraph` можно вводить режимы:
|
||||
- `full` (все ответы)
|
||||
- `compact` (первые N + hasMore)
|
||||
- Для клиентской синхронизации можно добавить `syncToken` (версия снимка данных), чтобы отличать повторный ответ от изменений после запроса.
|
||||
208
Dev_Docs/API/06_Channels_Read_API.md
Normal file
208
Dev_Docs/API/06_Channels_Read_API.md
Normal file
@ -0,0 +1,208 @@
|
||||
# 06. Channels Read API
|
||||
|
||||
## Человеко-читаемое объяснение
|
||||
Эти 3 функции — это **чтение данных каналов** для UI:
|
||||
|
||||
1. `ListSubscriptionsFeed` — отдает данные для экрана списка каналов:
|
||||
- ваши каналы (личный + созданные вами),
|
||||
- каналы пользователей, на кого вы подписаны,
|
||||
- отдельные каналы, на которые вы подписаны напрямую.
|
||||
|
||||
2. `GetChannelMessages` — отдает полную ленту одного канала (пока без курсоров, загружается сразу целиком),
|
||||
включая версии сообщений, лайки и ответы.
|
||||
|
||||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||
предки, фокус-сообщение, потомки.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
|
||||
---
|
||||
|
||||
## 1) ListSubscriptionsFeed
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "ListSubscriptionsFeed",
|
||||
"requestId": "req-1",
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"limit": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "ListSubscriptionsFeed",
|
||||
"requestId": "req-1",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"ownedChannels": [
|
||||
{
|
||||
"channel": {
|
||||
"ownerLogin": "Alice",
|
||||
"ownerBlockchainName": "alice-001",
|
||||
"channelName": "0",
|
||||
"personal": true,
|
||||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 120,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 921, "blockHash": "..." },
|
||||
"text": "последняя версия текста",
|
||||
"createdAtMs": 1760000000000,
|
||||
"authorLogin": "Alice",
|
||||
"authorBlockchainName": "alice-001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"followedUsersChannels": [
|
||||
{
|
||||
"channel": {
|
||||
"ownerLogin": "Bob",
|
||||
"ownerBlockchainName": "bob-001",
|
||||
"channelName": "0",
|
||||
"personal": true,
|
||||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 540,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 922, "blockHash": "..." },
|
||||
"text": "последняя версия текста",
|
||||
"createdAtMs": 1760000100000,
|
||||
"authorLogin": "Bob",
|
||||
"authorBlockchainName": "bob-001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"followedChannels": [
|
||||
{
|
||||
"channel": {
|
||||
"ownerLogin": "Carl",
|
||||
"ownerBlockchainName": "carl-001",
|
||||
"channelName": "market",
|
||||
"personal": false,
|
||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 90,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||
"text": "актуальный текст",
|
||||
"createdAtMs": 1760001000000,
|
||||
"authorLogin": "Carl",
|
||||
"authorBlockchainName": "carl-001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) GetChannelMessages
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "GetChannelMessages",
|
||||
"requestId": "req-2",
|
||||
"payload": {
|
||||
"channel": {
|
||||
"ownerBlockchainName": "bob-001",
|
||||
"channelRootBlockNumber": 123,
|
||||
"channelRootBlockHash": "..."
|
||||
},
|
||||
"limit": 200,
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "GetChannelMessages",
|
||||
"requestId": "req-2",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"channel": {
|
||||
"ownerLogin": "Bob",
|
||||
"ownerBlockchainName": "bob-001",
|
||||
"channelName": "news",
|
||||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
||||
"authorLogin": "Bob",
|
||||
"authorBlockchainName": "bob-001",
|
||||
"createdAtMs": 1760000000000,
|
||||
"text": "текущая версия",
|
||||
"likesCount": 12,
|
||||
"repliesCount": 3,
|
||||
"versionsTotal": 4,
|
||||
"versions": [
|
||||
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
||||
{ "versionIndex": 2, "blockNumber": 155, "blockHash": "...", "text": "v2", "createdAtMs": 1760001000000 },
|
||||
{ "versionIndex": 3, "blockNumber": 170, "blockHash": "...", "text": "v3", "createdAtMs": 1760002000000 },
|
||||
{ "versionIndex": 4, "blockNumber": 199, "blockHash": "...", "text": "v4", "createdAtMs": 1760003000000 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) GetMessageThread
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageThread",
|
||||
"requestId": "req-3",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "bob-001",
|
||||
"blockNumber": 333,
|
||||
"blockHash": "..."
|
||||
},
|
||||
"depthUp": 20,
|
||||
"depthDown": 2,
|
||||
"limitChildrenPerNode": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageThread",
|
||||
"requestId": "req-3",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"ancestors": [MessageNode],
|
||||
"focus": MessageNode,
|
||||
"descendants": [MessageNodeTree]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reason codes
|
||||
- `bad_fields`
|
||||
- `user_not_found`
|
||||
- `channel_not_found`
|
||||
- `message_not_found`
|
||||
- `limit_too_large`
|
||||
- `channel_name_already_exists`
|
||||
- `internal_error`
|
||||
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal file
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal file
@ -0,0 +1,140 @@
|
||||
# 07. Channels Feature Runbook (человеческое описание + диагностика)
|
||||
|
||||
## 1) Что уже сделано простыми словами
|
||||
|
||||
Сейчас реализован полный минимальный контур для каналов:
|
||||
|
||||
1. **Серверные read API**:
|
||||
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||
- `GetChannelMessages` — сообщения конкретного канала.
|
||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||
|
||||
2. **UI вкладки Каналы**:
|
||||
- при открытии пытается загрузить реальный feed с сервера;
|
||||
- если сервер недоступен — fallback на мок-данные;
|
||||
- группы каналов выводятся в нужном порядке;
|
||||
- есть кнопка «Добавить канал», модалки подписки, переход в канал.
|
||||
|
||||
3. **Проверка уникальности имени канала на сервере**
|
||||
- в `AddBlock` при `CreateChannelBody` добавлена проверка;
|
||||
- при дубле возвращается `409 channel_name_already_exists`.
|
||||
|
||||
---
|
||||
|
||||
## 2) Что тестировать в первую очередь (быстрый чеклист)
|
||||
|
||||
### Базовый smoke
|
||||
1. Авторизоваться в UI.
|
||||
2. Открыть вкладку «Каналы».
|
||||
3. Убедиться, что данные загрузились с сервера (или виден fallback-баннер).
|
||||
4. Нажать любой канал — должен открыться экран канала с сообщениями.
|
||||
|
||||
### API smoke
|
||||
1. Вызвать `ListSubscriptionsFeed`.
|
||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||
|
||||
### Ошибки
|
||||
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||
4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
|
||||
---
|
||||
|
||||
## 3) Готовые JSON-запросы для ручной диагностики
|
||||
|
||||
## 3.1 ListSubscriptionsFeed
|
||||
```json
|
||||
{
|
||||
"op": "ListSubscriptionsFeed",
|
||||
"requestId": "debug-feed-1",
|
||||
"payload": {
|
||||
"login": "TestUser1",
|
||||
"limit": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.2 GetChannelMessages
|
||||
```json
|
||||
{
|
||||
"op": "GetChannelMessages",
|
||||
"requestId": "debug-ch-1",
|
||||
"payload": {
|
||||
"channel": {
|
||||
"ownerBlockchainName": "TestUser1-001",
|
||||
"channelRootBlockNumber": 0,
|
||||
"channelRootBlockHash": ""
|
||||
},
|
||||
"limit": 200,
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.3 GetMessageThread
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageThread",
|
||||
"requestId": "debug-thread-1",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "TestUser1-001",
|
||||
"blockNumber": 123,
|
||||
"blockHash": "<hash-from-GetChannelMessages>"
|
||||
},
|
||||
"depthUp": 20,
|
||||
"depthDown": 2,
|
||||
"limitChildrenPerNode": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Что смотреть в ответах
|
||||
|
||||
### ListSubscriptionsFeed
|
||||
- `payload.login` — канонический login.
|
||||
- `ownedChannels / followedUsersChannels / followedChannels` — массивы.
|
||||
- у каждой записи есть:
|
||||
- `channel.channelRoot.blockNumber`,
|
||||
- `messagesCount`,
|
||||
- `lastMessage` (может быть null, если сообщений нет).
|
||||
|
||||
### GetChannelMessages
|
||||
- `payload.channel` заполнен;
|
||||
- `payload.messages[]` содержит:
|
||||
- `likesCount`, `repliesCount`,
|
||||
- `versionsTotal`, `versions[]`,
|
||||
- `text` должен быть текущей (последней) версией.
|
||||
|
||||
### GetMessageThread
|
||||
- `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`.
|
||||
- у узлов должны быть версии и счетчики.
|
||||
|
||||
---
|
||||
|
||||
## 5) Частые проблемы и как быстро локализовать
|
||||
|
||||
1. **`status != 200`, code=bad_fields**
|
||||
- проверить вложенность payload и обязательные поля.
|
||||
|
||||
2. **`message_not_found` в GetMessageThread**
|
||||
- обычно передали blockNumber/hash не из `messageRef`.
|
||||
|
||||
3. **Пустой список сообщений в GetChannelMessages**
|
||||
- проверить `ownerBlockchainName` и `channelRootBlockNumber`.
|
||||
|
||||
4. **`channel_name_already_exists` при AddBlock**
|
||||
- это ожидаемо: в этой цепочке уже есть канал с таким именем.
|
||||
|
||||
---
|
||||
|
||||
## 6) Для будущей доработки
|
||||
|
||||
1. Добавить курсоры (пагинацию) для больших каналов.
|
||||
2. Перевести «Подписаться»/«Добавить канал» в UI с демо-заглушек на реальные write RPC.
|
||||
3. Добавить batch-агрегации для thread/versions (оптимизация).
|
||||
4. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку.
|
||||
52
build.gradle
52
build.gradle
@ -113,8 +113,36 @@ tasks.named('test') {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.register('itCleanRun', JavaExec) {
|
||||
group = "build"
|
||||
tasks.register('cleanServerLogs') {
|
||||
group = "!!test"
|
||||
description = "Clear server logs/app.log and remove rolled log files"
|
||||
|
||||
doLast {
|
||||
File logsDir = file('logs')
|
||||
if (!logsDir.exists()) {
|
||||
logsDir.mkdirs()
|
||||
}
|
||||
|
||||
File appLog = new File(logsDir, 'app.log')
|
||||
if (!appLog.exists()) {
|
||||
appLog.createNewFile()
|
||||
}
|
||||
appLog.text = ''
|
||||
|
||||
fileTree(logsDir) {
|
||||
include 'app.*.log'
|
||||
}.files.each { File f ->
|
||||
if (!f.delete()) {
|
||||
throw new GradleException("Failed to delete log file: ${f.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
println "Server logs cleared: ${logsDir.absolutePath}"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('integrationTest', JavaExec) {
|
||||
group = "!!test"
|
||||
description = "Clean data → kill 7070 → start WS → run all IT tests"
|
||||
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
@ -127,17 +155,21 @@ tasks.register('itCleanRun', JavaExec) {
|
||||
dependsOn testClasses
|
||||
}
|
||||
|
||||
tasks.register('itDeployServer', JavaExec) {
|
||||
group = "build"
|
||||
tasks.named('build') {
|
||||
finalizedBy tasks.named('integrationTest')
|
||||
}
|
||||
|
||||
tasks.register('deployServer', JavaExec) {
|
||||
group = "!!deployment"
|
||||
description = "Build → upload to server → clean remote data → restart service → run IT against server"
|
||||
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "test.it.IT_DeployRestartAndRunRemoteMain"
|
||||
|
||||
// можно переопределить при запуске:
|
||||
// ./gradlew itDeployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||
dependsOn shadowJar
|
||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "10.147.20.7")
|
||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
||||
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
||||
systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data")
|
||||
@ -149,3 +181,11 @@ tasks.register('itDeployServer', JavaExec) {
|
||||
|
||||
dependsOn testClasses
|
||||
}
|
||||
|
||||
tasks.register('deployPWA', Exec) {
|
||||
group = "!!deployment"
|
||||
description = "Deploy PWA via deploy_shine-PWA.sh"
|
||||
|
||||
workingDir = rootDir
|
||||
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
|
||||
}
|
||||
|
||||
27
deploy_shine-PWA.sh
Executable file
27
deploy_shine-PWA.sh
Executable file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="shine-UI"
|
||||
REMOTE_HOST="root@194.87.0.247"
|
||||
REMOTE_DIR="/home/user/docker/caddyFile/sites/shine-UI"
|
||||
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
|
||||
export BUILD_VERSION
|
||||
|
||||
if [[ ! -d "$SRC_DIR" ]]; then
|
||||
echo "ERROR: source directory not found: $SRC_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Applying build version: $BUILD_VERSION"
|
||||
find "$SRC_DIR" -type f \( -name "*.js" -o -name "index.html" \) -print0 | xargs -0 perl -0pi -e 's/(\.js\?v=)([^"'"'"'\''\s>]*)/$1$ENV{BUILD_VERSION}/g; s/(\.css\?v=)([^"'"'"'\''\s>]*)/$1$ENV{BUILD_VERSION}/g'
|
||||
|
||||
echo "==> Checking SSH connectivity to $REMOTE_HOST"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
|
||||
|
||||
echo "==> Preparing remote directory: $REMOTE_DIR"
|
||||
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
|
||||
|
||||
echo "==> Syncing files from $SRC_DIR to $REMOTE_DIR"
|
||||
rsync -avz --delete "$SRC_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
||||
|
||||
echo "Всё хорошо"
|
||||
1509
shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log
Normal file
1509
shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log
Normal file
File diff suppressed because it is too large
Load Diff
40
shine-UI/AGENTS.md
Normal file
40
shine-UI/AGENTS.md
Normal file
@ -0,0 +1,40 @@
|
||||
# AGENTS
|
||||
|
||||
## Назначение проекта
|
||||
Это демо-прототип мобильного веб-приложения в формате статического сайта.
|
||||
|
||||
## Технические ограничения
|
||||
- Проект сделан без бэкенда, без базы данных и без реальных API.
|
||||
- Все данные моковые и хранятся в `js/mock-data.js`.
|
||||
- Навигация между экранами идет без полной перезагрузки страницы (SPA-подход на hash-router).
|
||||
|
||||
## Обязательные требования к каждому экрану
|
||||
- У каждого экрана есть явный верхний заголовок на русском языке.
|
||||
- У каждого экрана есть нижняя служебная подпись над toolbar в формате:
|
||||
`[Русское название] ([english-page-id])`.
|
||||
- `page-id` должен совпадать с именем JS-файла страницы или быть максимально близким к нему.
|
||||
|
||||
## Архитектурные правила
|
||||
- Структура проекта должна оставаться понятной и модульной.
|
||||
- Новые доработки нужно вносить аккуратно, не ломая существующую навигацию.
|
||||
- Стиль проекта: темная тема, mobile-first, интерфейс на русском языке.
|
||||
|
||||
## Экраны и файлы
|
||||
- Профиль: `js/pages/profile-view.js`
|
||||
- Кошелёк: `js/pages/wallet-view.js`
|
||||
- Настройки: `js/pages/settings-view.js`
|
||||
- Личные сообщения: `js/pages/messages-list.js`
|
||||
- Чат: `js/pages/chat-view.js`
|
||||
- Каналы: `js/pages/channels-list.js`
|
||||
- Канал: `js/pages/channel-view.js`
|
||||
- Связи: `js/pages/network-view.js`
|
||||
- Уведомления: `js/pages/notifications-view.js`
|
||||
|
||||
## Ключевые файлы приложения
|
||||
- Точка входа: `index.html`
|
||||
- Инициализация приложения: `js/app.js`
|
||||
- Роутинг: `js/router.js`
|
||||
- Состояние клиента: `js/state.js`
|
||||
- Моки: `js/mock-data.js`
|
||||
- Компоненты: `js/components/*`
|
||||
- Стили: `styles/*`
|
||||
83
shine-UI/img/device-qr-64.svg
Normal file
83
shine-UI/img/device-qr-64.svg
Normal file
@ -0,0 +1,83 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" shape-rendering="crispEdges" role="img" aria-label="QR demo 64x64">
|
||||
<rect width="64" height="64" fill="#ffffff"/>
|
||||
<rect x="0" y="0" width="16" height="16" fill="#000000"/>
|
||||
<rect x="2" y="2" width="12" height="12" fill="#ffffff"/>
|
||||
<rect x="4" y="4" width="8" height="8" fill="#000000"/>
|
||||
<rect x="48" y="0" width="16" height="16" fill="#000000"/>
|
||||
<rect x="50" y="2" width="12" height="12" fill="#ffffff"/>
|
||||
<rect x="52" y="4" width="8" height="8" fill="#000000"/>
|
||||
<rect x="0" y="48" width="16" height="16" fill="#000000"/>
|
||||
<rect x="2" y="50" width="12" height="12" fill="#ffffff"/>
|
||||
<rect x="4" y="52" width="8" height="8" fill="#000000"/>
|
||||
|
||||
<rect x="20" y="4" width="2" height="2" fill="#000000"/>
|
||||
<rect x="24" y="4" width="2" height="2" fill="#000000"/>
|
||||
<rect x="28" y="4" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="4" width="2" height="2" fill="#000000"/>
|
||||
<rect x="38" y="4" width="2" height="2" fill="#000000"/>
|
||||
<rect x="42" y="4" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="18" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="22" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="26" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="30" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="40" y="10" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="10" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="18" y="18" width="2" height="2" fill="#000000"/>
|
||||
<rect x="22" y="18" width="2" height="2" fill="#000000"/>
|
||||
<rect x="30" y="18" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="18" width="2" height="2" fill="#000000"/>
|
||||
<rect x="38" y="18" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="18" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="20" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="24" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="28" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="32" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="40" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="24" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="18" y="30" width="2" height="2" fill="#000000"/>
|
||||
<rect x="22" y="30" width="2" height="2" fill="#000000"/>
|
||||
<rect x="26" y="30" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="30" width="2" height="2" fill="#000000"/>
|
||||
<rect x="38" y="30" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="30" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="18" y="36" width="2" height="2" fill="#000000"/>
|
||||
<rect x="22" y="36" width="2" height="2" fill="#000000"/>
|
||||
<rect x="30" y="36" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="36" width="2" height="2" fill="#000000"/>
|
||||
<rect x="40" y="36" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="36" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="20" y="42" width="2" height="2" fill="#000000"/>
|
||||
<rect x="24" y="42" width="2" height="2" fill="#000000"/>
|
||||
<rect x="28" y="42" width="2" height="2" fill="#000000"/>
|
||||
<rect x="32" y="42" width="2" height="2" fill="#000000"/>
|
||||
<rect x="36" y="42" width="2" height="2" fill="#000000"/>
|
||||
<rect x="42" y="42" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="50" y="20" width="2" height="2" fill="#000000"/>
|
||||
<rect x="54" y="20" width="2" height="2" fill="#000000"/>
|
||||
<rect x="58" y="20" width="2" height="2" fill="#000000"/>
|
||||
<rect x="50" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="56" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="60" y="24" width="2" height="2" fill="#000000"/>
|
||||
<rect x="50" y="28" width="2" height="2" fill="#000000"/>
|
||||
<rect x="54" y="28" width="2" height="2" fill="#000000"/>
|
||||
<rect x="58" y="28" width="2" height="2" fill="#000000"/>
|
||||
|
||||
<rect x="20" y="50" width="2" height="2" fill="#000000"/>
|
||||
<rect x="24" y="50" width="2" height="2" fill="#000000"/>
|
||||
<rect x="30" y="50" width="2" height="2" fill="#000000"/>
|
||||
<rect x="36" y="50" width="2" height="2" fill="#000000"/>
|
||||
<rect x="42" y="50" width="2" height="2" fill="#000000"/>
|
||||
<rect x="20" y="54" width="2" height="2" fill="#000000"/>
|
||||
<rect x="28" y="54" width="2" height="2" fill="#000000"/>
|
||||
<rect x="34" y="54" width="2" height="2" fill="#000000"/>
|
||||
<rect x="40" y="54" width="2" height="2" fill="#000000"/>
|
||||
<rect x="44" y="54" width="2" height="2" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
BIN
shine-UI/img/logo.jpg
Normal file
BIN
shine-UI/img/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
20
shine-UI/index.html
Normal file
20
shine-UI/index.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Shine UI Demo</title>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=20260330210201" />
|
||||
<link rel="stylesheet" href="./styles/layout.css?v=20260330210201" />
|
||||
<link rel="stylesheet" href="./styles/components.css?v=20260330210201" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="page-label-slot" class="page-label-slot"></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script type="module" src="./js/app.js?v=20260330210201"></script>
|
||||
</body>
|
||||
</html>
|
||||
188
shine-UI/js/app.js
Normal file
188
shine-UI/js/app.js
Normal file
@ -0,0 +1,188 @@
|
||||
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260330210201';
|
||||
import { renderToolbar } from './components/toolbar.js?v=20260330210201';
|
||||
import { renderPageLabel } from './components/page-label.js?v=20260330210201';
|
||||
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260331000100';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setSessionResetHandler,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
togglePageLabel,
|
||||
} from './state.js?v=20260330210201';
|
||||
|
||||
import * as startView from './pages/start-view.js?v=20260330210201';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=20260330210201';
|
||||
import * as registerView from './pages/register-view.js?v=20260330210201';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=20260330210201';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js?v=20260330210201';
|
||||
import * as topupView from './pages/topup-view.js?v=20260330210201';
|
||||
import * as loginView from './pages/login-view.js?v=20260330210201';
|
||||
import * as loginCameraView from './pages/login-camera-view.js?v=20260330210201';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=20260330210201';
|
||||
import * as keyStorageView from './pages/key-storage-view.js?v=20260330210201';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=20260330210201';
|
||||
import * as walletView from './pages/wallet-view.js?v=20260330210201';
|
||||
import * as settingsView from './pages/settings-view.js?v=20260330210201';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=20260330210201';
|
||||
import * as deviceView from './pages/device-view.js?v=20260330210201';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=20260330210201';
|
||||
import * as deviceQrView from './pages/device-qr-view.js?v=20260330210201';
|
||||
import * as deviceCameraView from './pages/device-camera-view.js?v=20260330210201';
|
||||
import * as showKeysView from './pages/show-keys-view.js?v=20260330210201';
|
||||
import * as deviceSessionView from './pages/device-session-view.js?v=20260330210201';
|
||||
import * as languageView from './pages/language-view.js?v=20260330210201';
|
||||
import * as messagesList from './pages/messages-list.js?v=20260330210201';
|
||||
import * as contactSearchView from './pages/contact-search-view.js?v=20260330210201';
|
||||
import * as chatView from './pages/chat-view.js?v=20260330210201';
|
||||
import * as channelsList from './pages/channels-list.js?v=20260330210201';
|
||||
import * as channelView from './pages/channel-view.js?v=20260330210201';
|
||||
import * as addChannelView from './pages/add-channel-view.js?v=20260330210201';
|
||||
import * as networkView from './pages/network-view.js?v=20260330210201';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=20260330210201';
|
||||
|
||||
const routes = {
|
||||
'start-view': startView,
|
||||
'entry-settings-view': entrySettingsView,
|
||||
'register-view': registerView,
|
||||
'registration-payment-view': registrationPaymentView,
|
||||
'registration-keys-view': registrationKeysView,
|
||||
'topup-view': topupView,
|
||||
'login-view': loginView,
|
||||
'login-camera-view': loginCameraView,
|
||||
'login-password-view': loginPasswordView,
|
||||
'key-storage-view': keyStorageView,
|
||||
'profile-view': profileView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
'device-qr-view': deviceQrView,
|
||||
'device-camera-view': deviceCameraView,
|
||||
'show-keys-view': showKeysView,
|
||||
'device-session-view': deviceSessionView,
|
||||
'language-view': languageView,
|
||||
'messages-list': messagesList,
|
||||
'contact-search-view': contactSearchView,
|
||||
'chat-view': chatView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'add-channel-view': addChannelView,
|
||||
'network-view': networkView,
|
||||
'notifications-view': notificationsView,
|
||||
};
|
||||
|
||||
const screenEl = document.getElementById('app-screen');
|
||||
const labelEl = document.getElementById('page-label-slot');
|
||||
const toolbarEl = document.getElementById('toolbar-slot');
|
||||
|
||||
let currentCleanup = null;
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
captureClientError({
|
||||
kind: 'global_error',
|
||||
message: event.message || 'Global JS error',
|
||||
stack: event.error?.stack || '',
|
||||
sourceUrl: event.filename || '',
|
||||
lineNumber: event.lineno,
|
||||
columnNumber: event.colno,
|
||||
context: {
|
||||
pageId: getRoute().pageId || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason;
|
||||
captureClientError({
|
||||
kind: 'unhandled_rejection',
|
||||
message: reason?.message || String(reason || 'Unhandled promise rejection'),
|
||||
stack: reason?.stack || '',
|
||||
context: {
|
||||
pageId: getRoute().pageId || '',
|
||||
reasonType: reason?.constructor?.name || typeof reason,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function renderApp() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||
|
||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
|
||||
navigate('start-view');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
||||
navigate('profile-view');
|
||||
return;
|
||||
}
|
||||
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
|
||||
screenEl.innerHTML = '';
|
||||
const screen = page.render({ route, navigate });
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
|
||||
labelEl.innerHTML = '';
|
||||
toolbarEl.innerHTML = '';
|
||||
|
||||
if (showAppChrome) {
|
||||
labelEl.append(
|
||||
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
|
||||
togglePageLabel();
|
||||
renderApp();
|
||||
}),
|
||||
);
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAutoLogin() {
|
||||
if (!state.session.login || !state.session.sessionId) return;
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
|
||||
authorizeSession(resumed);
|
||||
await refreshSessions();
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
setSessionResetHandler(() => {
|
||||
navigate('start-view');
|
||||
});
|
||||
await tryAutoLogin();
|
||||
|
||||
if (!window.location.hash) {
|
||||
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||
} else {
|
||||
renderApp();
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', renderApp);
|
||||
}
|
||||
|
||||
init();
|
||||
31
shine-UI/js/components/header.js
Normal file
31
shine-UI/js/components/header.js
Normal file
@ -0,0 +1,31 @@
|
||||
export function renderHeader({ title, leftAction, rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
rightActions.forEach((action) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener('click', action.onClick);
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
return wrap;
|
||||
}
|
||||
36
shine-UI/js/components/page-label.js
Normal file
36
shine-UI/js/components/page-label.js
Normal file
@ -0,0 +1,36 @@
|
||||
export function renderPageLabel(titleRu, pageId, collapsed, onToggle) {
|
||||
const label = document.createElement('div');
|
||||
label.className = `page-label${collapsed ? ' is-collapsed' : ''}`;
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'page-label-toggle';
|
||||
toggle.title = collapsed ? 'Показать подпись' : 'Скрыть подпись';
|
||||
toggle.setAttribute(
|
||||
'aria-label',
|
||||
collapsed ? 'Показать подпись страницы для разработки' : 'Скрыть подпись страницы для разработки',
|
||||
);
|
||||
toggle.addEventListener('click', onToggle);
|
||||
|
||||
if (!collapsed) {
|
||||
label.append(toggle);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'page-label-content';
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'page-label-hint';
|
||||
hint.textContent = 'Для разработки';
|
||||
|
||||
const caption = document.createElement('div');
|
||||
caption.className = 'page-label-caption';
|
||||
caption.textContent = `${titleRu} (${pageId})`;
|
||||
|
||||
content.append(hint, caption);
|
||||
label.append(content);
|
||||
} else {
|
||||
label.append(toggle);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
25
shine-UI/js/components/toolbar.js
Normal file
25
shine-UI/js/components/toolbar.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { resolveToolbarActive } from '../router.js?v=20260330210201';
|
||||
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
|
||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
||||
];
|
||||
|
||||
export function renderToolbar(currentPageId, navigate) {
|
||||
const root = document.createElement('nav');
|
||||
root.className = 'toolbar';
|
||||
const active = resolveToolbarActive(currentPageId);
|
||||
|
||||
ITEMS.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}`;
|
||||
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => navigate(item.pageId));
|
||||
root.append(btn);
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
282
shine-UI/js/mock-data.js
Normal file
282
shine-UI/js/mock-data.js
Normal file
@ -0,0 +1,282 @@
|
||||
export const profile = {
|
||||
login: '@shine.alex',
|
||||
name: 'Алексей сияющий',
|
||||
avatarInitials: 'АС',
|
||||
phone: '+7 (916) 221-45-88',
|
||||
address: 'Москва, Пресненская наб., 12',
|
||||
email: 'alex.shine@demo.local',
|
||||
socials: '@alexshine / t.me/alexshine',
|
||||
badges: ['Официальный аккаунт', 'Сияющий'],
|
||||
};
|
||||
|
||||
export const wallet = {
|
||||
balanceSOL: '182.4571',
|
||||
publicAddress: '9sVAXJ2CqP3BrtC6AFeQHhcuWjN1kUyhY7L8pkQJxMZe',
|
||||
updatedAt: 'сегодня, 14:42',
|
||||
};
|
||||
|
||||
export const deviceSessions = [
|
||||
{
|
||||
sessionId: 'sess_7c5e5c4b',
|
||||
clientInfoFromClient: 'Android 15; Pixel 9',
|
||||
clientInfoFromRequest: 'UA=Java-http-client/17.0.18; remote=127.0.0.1',
|
||||
geo: 'RU/Moscow',
|
||||
lastAuthenticatedAtMs: 1774600010500,
|
||||
},
|
||||
{
|
||||
sessionId: 'sess_90ab11de',
|
||||
clientInfoFromClient: 'iOS 19; iPhone 17',
|
||||
clientInfoFromRequest: 'UA=ShineMobile/2.4; remote=10.0.2.12',
|
||||
geo: 'RU/Moscow',
|
||||
lastAuthenticatedAtMs: 1774553310000,
|
||||
},
|
||||
{
|
||||
sessionId: 'sess_3ea4f11c',
|
||||
clientInfoFromClient: 'Windows 11; Chrome 124',
|
||||
clientInfoFromRequest: 'UA=Mozilla/5.0; remote=192.168.1.21',
|
||||
geo: 'RU/Kazan',
|
||||
lastAuthenticatedAtMs: 1774499010000,
|
||||
},
|
||||
];
|
||||
|
||||
export const directMessages = [
|
||||
{
|
||||
id: 'u1',
|
||||
name: 'Марина К.',
|
||||
initials: 'МК',
|
||||
lastMessage: 'Вечером скину обновления по макетам.',
|
||||
time: '15:08',
|
||||
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 = [
|
||||
{
|
||||
id: 'u5',
|
||||
name: 'Марк С.',
|
||||
initials: 'МС',
|
||||
about: 'Продуктовый аналитик, любит короткие созвоны и длинные отчёты.',
|
||||
},
|
||||
{
|
||||
id: 'u6',
|
||||
name: 'Мария Л.',
|
||||
initials: 'МЛ',
|
||||
about: 'UI-дизайнер, собирает референсы и следит за визуальным стилем.',
|
||||
},
|
||||
{
|
||||
id: 'u7',
|
||||
name: 'Марина Р.',
|
||||
initials: 'МР',
|
||||
about: 'Контент-менеджер, ведёт каналы и готовит анонсы.',
|
||||
},
|
||||
{
|
||||
id: 'u8',
|
||||
name: 'Максим В.',
|
||||
initials: 'МВ',
|
||||
about: 'Frontend-разработчик, отвечает за анимации и адаптивность.',
|
||||
},
|
||||
{
|
||||
id: 'u9',
|
||||
name: 'Мадина А.',
|
||||
initials: 'МА',
|
||||
about: 'Комьюнити-менеджер, быстро находит нужных людей.',
|
||||
},
|
||||
{
|
||||
id: 'u10',
|
||||
name: 'Ирина П.',
|
||||
initials: 'ИП',
|
||||
about: 'Редактор новостей, помогает с текстами и публикациями.',
|
||||
},
|
||||
{
|
||||
id: 'u11',
|
||||
name: 'Николай Д.',
|
||||
initials: 'НД',
|
||||
about: 'Технический писатель, структурирует знания по продукту.',
|
||||
},
|
||||
{
|
||||
id: 'u12',
|
||||
name: 'Егор Т.',
|
||||
initials: 'ЕТ',
|
||||
about: 'QA-инженер, любит проверять сложные сценарии вручную.',
|
||||
},
|
||||
];
|
||||
|
||||
export const chatMessages = {
|
||||
u1: [
|
||||
{ from: 'in', text: 'Привет! Видел новые карточки?' },
|
||||
{ from: 'out', text: 'Да, смотрятся сильно. Нужен финальный текст.' },
|
||||
{ from: 'in', text: 'Вечером скину обновления по макетам.' },
|
||||
],
|
||||
u2: [
|
||||
{ from: 'out', text: 'Скинул доступы в чат команды.' },
|
||||
{ from: 'in', text: 'Спасибо, уже проверяю!' },
|
||||
],
|
||||
u3: [
|
||||
{ from: 'in', text: 'Тестовый стенд снова доступен.' },
|
||||
{ from: 'out', text: 'Отлично, запускаю прогон сценариев.' },
|
||||
],
|
||||
u4: [
|
||||
{ from: 'in', text: 'Подтверждаю план на завтра.' },
|
||||
{ from: 'out', text: 'Отлично, давай так и сделаем.' },
|
||||
],
|
||||
};
|
||||
|
||||
export const channels = [
|
||||
{
|
||||
id: 'ch0',
|
||||
name: 'Личный канал',
|
||||
initials: 'ЛК',
|
||||
ownerLogin: '@shine.alex',
|
||||
ownerName: 'Вы',
|
||||
description: 'Ваш основной канал (нулевой).',
|
||||
lastMessage: 'Добро пожаловать в личный канал.',
|
||||
time: '16:05',
|
||||
messagesCount: 14,
|
||||
kind: 'own-personal',
|
||||
},
|
||||
{
|
||||
id: 'ch1',
|
||||
name: 'Команда продукта',
|
||||
initials: 'КП',
|
||||
ownerLogin: '@shine.alex',
|
||||
ownerName: 'Вы',
|
||||
description: 'Канал команды, который вы создали.',
|
||||
lastMessage: 'Обновили roadmap на апрель.',
|
||||
time: '15:42',
|
||||
messagesCount: 8,
|
||||
kind: 'own',
|
||||
},
|
||||
{
|
||||
id: 'ch2',
|
||||
name: 'Новости Bob',
|
||||
initials: 'NB',
|
||||
ownerLogin: '@bob',
|
||||
ownerName: 'Bob',
|
||||
description: 'Основной канал пользователя Bob.',
|
||||
lastMessage: 'Вышел новый дайджест разработчика.',
|
||||
time: '15:20',
|
||||
messagesCount: 5,
|
||||
kind: 'followed-user-channel',
|
||||
},
|
||||
{
|
||||
id: 'ch3',
|
||||
name: 'Стендап команды Bob',
|
||||
initials: 'SB',
|
||||
ownerLogin: '@bob',
|
||||
ownerName: 'Bob',
|
||||
description: 'Второй канал пользователя Bob.',
|
||||
lastMessage: 'Перенесли созвон на 19:30.',
|
||||
time: 'вчера',
|
||||
messagesCount: 11,
|
||||
kind: 'followed-user-channel',
|
||||
},
|
||||
{
|
||||
id: 'ch4',
|
||||
name: 'Анекдоты дня',
|
||||
initials: 'АД',
|
||||
ownerLogin: '@fun.club',
|
||||
ownerName: 'Fun Club',
|
||||
description: 'Публичный развлекательный канал по подписке.',
|
||||
lastMessage: 'Сегодня в выпуске 5 новых шуток.',
|
||||
time: 'вчера',
|
||||
messagesCount: 33,
|
||||
kind: 'subscribed',
|
||||
},
|
||||
];
|
||||
|
||||
export const channelPosts = {
|
||||
ch0: [
|
||||
{
|
||||
id: 'p0-1',
|
||||
title: 'Первый личный пост',
|
||||
body: 'Этот канал всегда ваш и стоит в списке первым.',
|
||||
},
|
||||
{
|
||||
id: 'p0-2',
|
||||
title: 'Планы',
|
||||
body: 'Сюда удобно сохранять личные заметки и объявления.',
|
||||
},
|
||||
],
|
||||
ch1: [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'Новый экран профиля',
|
||||
body: 'Добавлены бейджи статуса, переработан верхний блок и улучшены быстрые переходы.',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
title: 'Навигация без перезагрузки',
|
||||
body: 'Переходы между экранами теперь стабильнее работают в SPA-режиме через hash-router.',
|
||||
},
|
||||
],
|
||||
ch2: [
|
||||
{
|
||||
id: 'p3',
|
||||
title: 'Анекдот утра',
|
||||
body: 'Разработчик говорит: "Я починил один баг". Баги в ответ: "Нас было трое".',
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
title: 'Анекдот про дедлайн',
|
||||
body: 'Дедлайн был настолько близко, что команда начала здороваться с ним по имени.',
|
||||
},
|
||||
],
|
||||
ch3: [
|
||||
{
|
||||
id: 'p5',
|
||||
title: 'Утренний дайджест',
|
||||
body: 'Собрали ключевые новости дня: обновления продуктов, движения рынка и заметные релизы.',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
title: 'Что обсуждают сегодня',
|
||||
body: 'В фокусе дня: рост интереса к мобильным dApp-интерфейсам и новые анонсы сообществ.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const notifications = {
|
||||
replies: [
|
||||
{ id: 'r1', title: 'Марина К. ответила на ваш комментарий', text: 'Согласна, такую структуру и оставим.', time: '12 минут назад' },
|
||||
{ id: 'r2', title: 'Илья П. ответил в обсуждении', text: 'Добавил примеры экранов для onboarding.', time: '48 минут назад' },
|
||||
],
|
||||
events: [
|
||||
{ id: 'e1', title: 'Елена Д. добавила вас в друзья', text: 'Теперь вы в связях первого уровня.', time: 'сегодня' },
|
||||
{ id: 'e2', title: 'Никита О. удалил из друзей', text: 'Связь перенесена в архив событий.', time: 'вчера' },
|
||||
{ id: 'e3', title: 'Марина К. поставила лайк', text: 'Оценен ваш пост о прототипе.', time: '2 дня назад' },
|
||||
],
|
||||
};
|
||||
|
||||
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 },
|
||||
],
|
||||
};
|
||||
38
shine-UI/js/pages/add-channel-view.js
Normal file
38
shine-UI/js/pages/add-channel-view.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Добавить канал',
|
||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||
})
|
||||
);
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
form.innerHTML = `
|
||||
<label for="channel-name">Имя канала</label>
|
||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
||||
<button type="submit" class="primary-btn">Создать</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
navigate('channels-list');
|
||||
});
|
||||
|
||||
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
|
||||
navigate('channels-list');
|
||||
});
|
||||
|
||||
screen.append(form);
|
||||
return screen;
|
||||
}
|
||||
184
shine-UI/js/pages/channel-view.js
Normal file
184
shine-UI/js/pages/channel-view.js
Normal file
@ -0,0 +1,184 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { channelPosts, channels } from '../mock-data.js?v=20260330210201';
|
||||
import { addLocalChannelPost, authService, getLocalChannelPosts, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
|
||||
function findMockChannel(channelId) {
|
||||
const channel = channels.find((c) => c.id === channelId) || channels[0];
|
||||
return {
|
||||
channel,
|
||||
posts: [
|
||||
...(channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
|
||||
...getLocalChannelPosts(channelId),
|
||||
],
|
||||
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
||||
};
|
||||
}
|
||||
|
||||
function mapApiMessageToPost(message) {
|
||||
return {
|
||||
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
|
||||
body: message.text || '(пусто)',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPostCard(post) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelId, channelName, onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">Новое сообщение в канал</h3>
|
||||
<p class="meta-muted"># ${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Введите текст сообщения"></textarea>
|
||||
<div class="meta-muted" id="channel-message-error" style="min-height:18px;"></div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#channel-message-text');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
||||
root.querySelector('#channel-message-submit').addEventListener('click', () => {
|
||||
const body = textEl.value.trim();
|
||||
if (!body) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
title: `${state.session.login || 'Вы'} • сейчас`,
|
||||
body,
|
||||
});
|
||||
close();
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderBody(screen, navigate, channelId, channelData) {
|
||||
const head = document.createElement('div');
|
||||
head.className = 'card';
|
||||
head.innerHTML = `
|
||||
<strong># ${channelData.channel.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
|
||||
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
|
||||
`;
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
|
||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
|
||||
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack';
|
||||
|
||||
channelData.posts.forEach((post) => {
|
||||
feed.append(renderPostCard(post));
|
||||
});
|
||||
|
||||
if (channelData.isOwnChannel) {
|
||||
actionButton.addEventListener('click', () => {
|
||||
openAddMessageModal({
|
||||
channelId,
|
||||
channelName: channelData.channel.name,
|
||||
onSubmit: (post) => {
|
||||
addLocalChannelPost(channelId, post);
|
||||
channelData.posts.push(post);
|
||||
feed.append(renderPostCard(post));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'secondary-btn';
|
||||
backButton.textContent = 'Назад к списку';
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
screen.append(head, actionButton, feed, backButton);
|
||||
}
|
||||
|
||||
async function loadFromApi(channelId) {
|
||||
const summary = state.channelsIndex[channelId];
|
||||
if (!summary) return null;
|
||||
|
||||
const selector = {
|
||||
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
||||
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
|
||||
channelRootBlockHash: summary.channel?.channelRoot?.blockHash,
|
||||
};
|
||||
|
||||
if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null;
|
||||
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc');
|
||||
const posts = [
|
||||
...(payload.messages || []).map(mapApiMessageToPost),
|
||||
...getLocalChannelPosts(channelId),
|
||||
];
|
||||
|
||||
return {
|
||||
channel: {
|
||||
name: payload.channel?.channelName || summary.channel?.channelName || 'unknown',
|
||||
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
|
||||
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
|
||||
},
|
||||
posts,
|
||||
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const channelId = route.params.channelId || 'ch1';
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
|
||||
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
|
||||
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: headerTitle,
|
||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||
})
|
||||
);
|
||||
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'card meta-muted';
|
||||
loading.textContent = 'Загрузка канала...';
|
||||
screen.append(loading);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const apiData = await loadFromApi(channelId);
|
||||
loading.remove();
|
||||
if (apiData) {
|
||||
renderBody(screen, navigate, channelId, apiData);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fallback to mock below
|
||||
}
|
||||
|
||||
loading.remove();
|
||||
renderBody(screen, navigate, channelId, findMockChannel(channelId));
|
||||
})();
|
||||
|
||||
return screen;
|
||||
}
|
||||
184
shine-UI/js/pages/channels-list.js
Normal file
184
shine-UI/js/pages/channels-list.js
Normal file
@ -0,0 +1,184 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { channels as mockChannels } from '../mock-data.js?v=20260330210201';
|
||||
import { authService, setChannelsFeed, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
function openSimpleSubscribeModal(kindLabel) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channels-subscribe-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">${kindLabel}</h3>
|
||||
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
|
||||
<input id="subscribe-input" class="input" placeholder="@login или #канал" />
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<button class="secondary-btn" id="sub-cancel">Отмена</button>
|
||||
<button class="primary-btn" id="sub-submit">Подписаться</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#sub-cancel').addEventListener('click', close);
|
||||
root.querySelector('#sub-submit').addEventListener('click', close);
|
||||
}
|
||||
|
||||
function initialsFromName(name = '') {
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
|
||||
}
|
||||
|
||||
function mapMockGroups() {
|
||||
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
|
||||
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
|
||||
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
|
||||
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
|
||||
}
|
||||
|
||||
function mapApiChannelRow(summary, bucketKey, idx, index) {
|
||||
const rowId = `${bucketKey}-${idx}`;
|
||||
index[rowId] = summary;
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
source: 'api',
|
||||
ownerName: summary.channel?.ownerLogin || 'unknown',
|
||||
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
|
||||
name: summary.channel?.channelName || '(без имени)',
|
||||
description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
||||
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
|
||||
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '—',
|
||||
messagesCount: summary.messagesCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapApiFeed(feed) {
|
||||
const index = {};
|
||||
|
||||
const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index));
|
||||
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index));
|
||||
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index));
|
||||
|
||||
ownChannels.sort((a, b) => {
|
||||
const ap = index[a.id]?.channel?.personal === true;
|
||||
const bp = index[b.id]?.channel?.personal === true;
|
||||
if (ap && !bp) return -1;
|
||||
if (!ap && bp) return 1;
|
||||
return a.name.localeCompare(b.name, 'ru');
|
||||
});
|
||||
|
||||
return { ownChannels, followedUserChannels, subscribedChannels, index };
|
||||
}
|
||||
|
||||
function renderChannelRow(channel, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.initials}</div>
|
||||
<div>
|
||||
<strong># ${channel.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
|
||||
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
|
||||
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</p>
|
||||
</div>
|
||||
<div style="display:grid; justify-items:end; gap:6px;">
|
||||
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
|
||||
<span class="meta-muted">${channel.time}</span>
|
||||
<span class="unread">${channel.messagesCount}</span>
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSection(title, items, navigate) {
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.className = 'section-title';
|
||||
header.textContent = title;
|
||||
|
||||
wrap.append(header);
|
||||
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderGroupedList(screen, navigate, groups) {
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'channels-scroll-wrap';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack channels-groups';
|
||||
|
||||
list.append(renderSection('Мои каналы', groups.ownChannels, navigate));
|
||||
|
||||
const dividerOne = document.createElement('hr');
|
||||
dividerOne.className = 'channels-divider';
|
||||
list.append(dividerOne);
|
||||
|
||||
list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate));
|
||||
|
||||
const dividerTwo = document.createElement('hr');
|
||||
dividerTwo.className = 'channels-divider';
|
||||
list.append(dividerTwo);
|
||||
|
||||
list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate));
|
||||
|
||||
const addChannelButton = document.createElement('button');
|
||||
addChannelButton.className = 'primary-btn';
|
||||
addChannelButton.textContent = 'Добавить канал';
|
||||
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
list.append(addChannelButton);
|
||||
|
||||
const scrollHint = document.createElement('div');
|
||||
scrollHint.className = 'channels-scroll-hint';
|
||||
|
||||
listWrap.append(list, scrollHint);
|
||||
screen.append(listWrap);
|
||||
}
|
||||
|
||||
async function loadFeedAndRender(screen, navigate) {
|
||||
const status = document.createElement('div');
|
||||
status.className = 'card meta-muted';
|
||||
status.textContent = 'Загрузка каналов с сервера...';
|
||||
screen.append(status);
|
||||
|
||||
try {
|
||||
if (!state.session.login) throw new Error('not_authorized');
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
const groups = mapApiFeed(feed);
|
||||
setChannelsFeed(feed, groups.index);
|
||||
status.remove();
|
||||
renderGroupedList(screen, navigate, groups);
|
||||
} catch {
|
||||
setChannelsFeed(null, {});
|
||||
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
|
||||
renderGroupedList(screen, navigate, mapMockGroups());
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Каналы',
|
||||
rightActions: [
|
||||
{ label: 'Подписаться на человека', onClick: () => openSimpleSubscribeModal('Подписка на человека') },
|
||||
{ label: 'Подписаться на канал', onClick: () => openSimpleSubscribeModal('Подписка на канал') },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
loadFeedAndRender(screen, navigate);
|
||||
return screen;
|
||||
}
|
||||
58
shine-UI/js/pages/chat-view.js
Normal file
58
shine-UI/js/pages/chat-view.js
Normal file
@ -0,0 +1,58 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { directMessages } from '../mock-data.js?v=20260330210201';
|
||||
import { addChatMessage, getChatMessages } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
function renderLog(list, chatId) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
messages.forEach((msg) => {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `bubble ${msg.from}`;
|
||||
bubble.textContent = msg.text;
|
||||
list.append(bubble);
|
||||
});
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const chatId = route.params.chatId || 'u1';
|
||||
const contact = directMessages.find((d) => d.id === chatId) || directMessages[0];
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: `Чат: ${contact.name}`,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
})
|
||||
);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap';
|
||||
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'chat-input';
|
||||
form.innerHTML = `
|
||||
<input class="input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
||||
<button class="primary-btn" type="submit">Отправить</button>
|
||||
`;
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const input = form.elements.message;
|
||||
addChatMessage(chatId, input.value);
|
||||
input.value = '';
|
||||
renderLog(log, chatId);
|
||||
});
|
||||
|
||||
renderLog(log, chatId);
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
return screen;
|
||||
}
|
||||
103
shine-UI/js/pages/connect-device-view.js
Normal file
103
shine-UI/js/pages/connect-device-view.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Подключить устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<p>Выберите, какие ключи передать на подключаемое устройство</p>
|
||||
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
|
||||
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
|
||||
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
|
||||
<div class="row">
|
||||
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<button class="primary-btn" type="button" id="open-qr">Показать QR-код для подключения</button>
|
||||
<button class="text-btn" type="button" id="open-camera">Подключить через камеру</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const rootToggle = card.querySelector('#connect-root');
|
||||
const blockchainToggle = card.querySelector('#connect-blockchain');
|
||||
const deviceToggle = card.querySelector('#connect-device');
|
||||
deviceToggle.checked = true;
|
||||
|
||||
rootToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.root = rootToggle.checked;
|
||||
});
|
||||
blockchainToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||
});
|
||||
deviceToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.device = true;
|
||||
deviceToggle.checked = true;
|
||||
});
|
||||
|
||||
const helpModal = document.createElement('div');
|
||||
helpModal.className = 'modal-shell';
|
||||
helpModal.hidden = true;
|
||||
helpModal.innerHTML = `
|
||||
<div class="modal-backdrop" data-close="true"></div>
|
||||
<div class="modal-dialog card" role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<h3 style="font-size:18px;">Техсправка</h3>
|
||||
<button class="icon-btn" type="button" data-close="true" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
|
||||
<p class="meta-muted">передать можно только существующие ключи</p>
|
||||
<p class="meta-muted">если ключа нет — он недоступен</p>
|
||||
<p class="meta-muted">blockchain key — можно передать или нет</p>
|
||||
<p class="meta-muted">root key — только если существует</p>
|
||||
<p class="meta-muted">device key передаётся всегда</p>
|
||||
<p class="meta-muted">подключение происходит напрямую через QR</p>
|
||||
<p class="meta-muted">сервер не используется</p>
|
||||
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
|
||||
<p class="meta-muted">обратный сценарий пока не реализован</p>
|
||||
</div>
|
||||
<button class="primary-btn" type="button" data-close="true">OK</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const openHelp = () => {
|
||||
helpModal.hidden = false;
|
||||
helpModal.querySelector('.modal-dialog').focus();
|
||||
};
|
||||
|
||||
const closeHelp = () => {
|
||||
helpModal.hidden = true;
|
||||
};
|
||||
|
||||
card.querySelector('#tech-help').addEventListener('click', openHelp);
|
||||
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
|
||||
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
|
||||
|
||||
helpModal.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.dataset.close === 'true') {
|
||||
closeHelp();
|
||||
}
|
||||
});
|
||||
|
||||
helpModal.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeHelp();
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(card, helpModal);
|
||||
return screen;
|
||||
}
|
||||
129
shine-UI/js/pages/contact-search-view.js
Normal file
129
shine-UI/js/pages/contact-search-view.js
Normal file
@ -0,0 +1,129 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { contactDirectory, directMessages } from '../mock-data.js?v=20260330210201';
|
||||
import { ensureChat } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
||||
|
||||
function getMatches(query) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return [];
|
||||
|
||||
return contactDirectory
|
||||
.filter((contact) => contact.name.toLowerCase().startsWith(normalized))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.name = 'contact';
|
||||
input.placeholder = 'Введите имя контакта';
|
||||
input.autocomplete = 'off';
|
||||
input.maxLength = 80;
|
||||
|
||||
const resultsCard = document.createElement('section');
|
||||
resultsCard.className = 'card stack';
|
||||
resultsCard.hidden = true;
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
|
||||
const resultsList = document.createElement('div');
|
||||
resultsList.className = 'stack';
|
||||
|
||||
let latestMatches = [];
|
||||
|
||||
const renderResults = (matches, query) => {
|
||||
latestMatches = matches;
|
||||
resultsList.innerHTML = '';
|
||||
resultsCard.hidden = false;
|
||||
|
||||
if (!query.trim()) {
|
||||
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matches.length) {
|
||||
status.textContent = 'Совпадений не найдено.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = `Найдено пользователей: ${matches.length}`;
|
||||
|
||||
matches.forEach((contact) => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${contact.initials}</div>
|
||||
<div>
|
||||
<strong>${contact.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p>
|
||||
</div>
|
||||
<div class="meta-muted">Контакт</div>
|
||||
`;
|
||||
resultsList.append(row);
|
||||
});
|
||||
};
|
||||
|
||||
const searchButton = document.createElement('button');
|
||||
searchButton.className = 'primary-btn';
|
||||
searchButton.type = 'button';
|
||||
searchButton.textContent = 'Найти';
|
||||
searchButton.addEventListener('click', () => {
|
||||
renderResults(getMatches(input.value), input.value);
|
||||
});
|
||||
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'ghost-btn';
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Добавить';
|
||||
addButton.addEventListener('click', () => {
|
||||
if (!latestMatches.length) {
|
||||
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
|
||||
resultsCard.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const contact = latestMatches[0];
|
||||
const exists = directMessages.some((item) => item.id === contact.id);
|
||||
|
||||
if (!exists) {
|
||||
directMessages.unshift({
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
initials: contact.initials,
|
||||
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
|
||||
time: 'сейчас',
|
||||
unread: 0,
|
||||
});
|
||||
}
|
||||
|
||||
ensureChat(contact.id);
|
||||
navigate(`chat-view/${contact.id}`);
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'contact-search-actions';
|
||||
controls.append(searchButton, addButton);
|
||||
|
||||
const formCard = document.createElement('section');
|
||||
formCard.className = 'card stack';
|
||||
formCard.append(input, controls);
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Поиск контактов',
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}),
|
||||
formCard,
|
||||
resultsCard,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
26
shine-UI/js/pages/device-camera-view.js
Normal file
26
shine-UI/js/pages/device-camera-view.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Подключить через камеру',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'camera-shell';
|
||||
frame.innerHTML = `
|
||||
<div class="camera-placeholder">Область камеры (демо-заглушка)</div>
|
||||
<div class="camera-frame"></div>
|
||||
<div class="camera-hint">Логика сканирования пока не реализована</div>
|
||||
`;
|
||||
|
||||
screen.append(frame);
|
||||
return screen;
|
||||
}
|
||||
36
shine-UI/js/pages/device-qr-view.js
Normal file
36
shine-UI/js/pages/device-qr-view.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { profile } from '../mock-data.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const selectedKeys = [];
|
||||
if (state.deviceConnect.root) selectedKeys.push('root key');
|
||||
if (state.deviceConnect.blockchain) selectedKeys.push('blockchain key');
|
||||
if (state.deviceConnect.device) selectedKeys.push('device key');
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Показать QR-код',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack qr-card';
|
||||
card.innerHTML = `
|
||||
<img class="qr-image" src="img/device-qr-64.svg" width="64" height="64" alt="QR-код для подключения" />
|
||||
<p class="meta-muted">Логин пользователя: ${profile.login}</p>
|
||||
<p class="meta-muted">Передаваемые ключи: ${selectedKeys.join(', ')}</p>
|
||||
<button class="primary-btn" type="button" id="qr-ok">OK</button>
|
||||
`;
|
||||
|
||||
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
|
||||
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
101
shine-UI/js/pages/device-session-view.js
Normal file
101
shine-UI/js/pages/device-session-view.js
Normal file
@ -0,0 +1,101 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import {
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setAuthError,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' };
|
||||
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const sessionId = route?.params?.sessionId || '';
|
||||
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Сеанс устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card';
|
||||
empty.textContent = 'Сеанс не найден.';
|
||||
screen.append(empty);
|
||||
return screen;
|
||||
}
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'card stack';
|
||||
details.innerHTML = `
|
||||
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
|
||||
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
|
||||
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
|
||||
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
|
||||
<div><p class="meta-muted">дата/время</p><p>${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</p></div>
|
||||
`;
|
||||
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'text-btn';
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.textContent = 'Завершить сеанс';
|
||||
|
||||
actionBtn.addEventListener('click', async () => {
|
||||
const isCurrentSession = session.sessionId === state.session.sessionId;
|
||||
const confirmed = window.confirm(
|
||||
isCurrentSession ? 'Хотите завершить текущую сессию?' : 'Хотите завершить этот сеанс?',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await authService.closeSession(session.sessionId);
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrentSession) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshSessions();
|
||||
navigate('device-view');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(details, actionBtn);
|
||||
return screen;
|
||||
}
|
||||
146
shine-UI/js/pages/device-view.js
Normal file
146
shine-UI/js/pages/device-view.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import {
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
const sessionsBlock = document.createElement('div');
|
||||
sessionsBlock.className = 'card stack';
|
||||
|
||||
const buildList = () => {
|
||||
sessionsBlock.innerHTML = '';
|
||||
const sessions = state.sessions || [];
|
||||
const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0];
|
||||
const others = sessions.filter((s) => s.sessionId !== current?.sessionId);
|
||||
|
||||
const createSessionItem = (session, isCurrent) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.type = 'button';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||
</div>
|
||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
</div>
|
||||
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||
`;
|
||||
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
||||
return item;
|
||||
};
|
||||
|
||||
if (!current) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.textContent = 'Активные сессии не найдены.';
|
||||
sessionsBlock.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMenu = document.createElement('div');
|
||||
currentMenu.className = 'stack';
|
||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'text-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('Хотите завершить текущую сессию?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await authService.closeSession(state.session.sessionId);
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
});
|
||||
currentMenu.append(endCurrentSessionBtn);
|
||||
|
||||
const othersMenu = document.createElement('div');
|
||||
othersMenu.className = 'stack';
|
||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные активные сеансы</p>';
|
||||
|
||||
if (others.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.textContent = 'Других активных сеансов нет.';
|
||||
othersMenu.append(empty);
|
||||
} else {
|
||||
others.forEach((session) => {
|
||||
othersMenu.append(createSessionItem(session, false));
|
||||
});
|
||||
}
|
||||
|
||||
sessionsBlock.append(currentMenu, othersMenu);
|
||||
};
|
||||
|
||||
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список сессий обновлён.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
buildList();
|
||||
screen.append(actions, sessionsBlock);
|
||||
return screen;
|
||||
}
|
||||
161
shine-UI/js/pages/entry-settings-view.js
Normal file
161
shine-UI/js/pages/entry-settings-view.js
Normal file
@ -0,0 +1,161 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServer', label: 'Адрес сервера Сияние' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const draft = {
|
||||
language: state.entrySettings.language,
|
||||
solanaServer: state.entrySettings.solanaServer,
|
||||
shineServer: state.entrySettings.shineServer,
|
||||
arweaveServer: state.entrySettings.arweaveServer,
|
||||
statuses: { ...state.entrySettings.statuses },
|
||||
};
|
||||
|
||||
const timers = new Map();
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card stack';
|
||||
|
||||
const languageLabel = document.createElement('label');
|
||||
languageLabel.className = 'stack';
|
||||
languageLabel.innerHTML = `<span class="field-label">Язык</span>`;
|
||||
|
||||
const languageSelect = document.createElement('select');
|
||||
languageSelect.className = 'select';
|
||||
languageSelect.innerHTML = `
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
`;
|
||||
languageSelect.value = draft.language;
|
||||
languageSelect.addEventListener('change', () => {
|
||||
draft.language = languageSelect.value;
|
||||
});
|
||||
languageLabel.append(languageSelect);
|
||||
|
||||
body.append(languageLabel);
|
||||
|
||||
SERVER_FIELDS.forEach((field) => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'stack';
|
||||
|
||||
const title = document.createElement('label');
|
||||
title.className = 'field-label';
|
||||
title.textContent = field.label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.value = draft[field.key];
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'status-line';
|
||||
|
||||
const applyStatus = (value) => {
|
||||
draft.statuses[field.key] = value;
|
||||
checkButton.classList.remove('is-available', 'is-unavailable');
|
||||
status.classList.remove('is-available', 'is-unavailable');
|
||||
|
||||
if (value === 'available') {
|
||||
status.textContent = 'Доступен';
|
||||
checkButton.classList.add('is-available');
|
||||
status.classList.add('is-available');
|
||||
} else if (value === 'unavailable') {
|
||||
status.textContent = 'Недоступен';
|
||||
checkButton.classList.add('is-unavailable');
|
||||
status.classList.add('is-unavailable');
|
||||
} else {
|
||||
status.textContent = 'Статус не проверен';
|
||||
}
|
||||
};
|
||||
|
||||
const runCheck = () => {
|
||||
draft[field.key] = input.value.trim();
|
||||
applyStatus(checkServerAvailability(input.value));
|
||||
};
|
||||
|
||||
applyStatus(draft.statuses[field.key]);
|
||||
|
||||
checkButton.addEventListener('click', runCheck);
|
||||
input.addEventListener('input', () => {
|
||||
draft[field.key] = input.value;
|
||||
applyStatus('idle');
|
||||
window.clearTimeout(timers.get(field.key));
|
||||
timers.set(field.key, window.setTimeout(runCheck, 3000));
|
||||
});
|
||||
input.addEventListener('blur', runCheck);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
runCheck();
|
||||
}
|
||||
});
|
||||
|
||||
controls.append(checkButton, status);
|
||||
block.append(title, input, controls);
|
||||
body.append(block);
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', () => {
|
||||
saveEntrySettings(draft);
|
||||
navigate('start-view');
|
||||
});
|
||||
|
||||
actions.append(cancelButton, saveButton);
|
||||
|
||||
const help = document.createElement('button');
|
||||
help.className = 'help-fab';
|
||||
help.type = 'button';
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
window.alert(
|
||||
'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
|
||||
);
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки входа',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
body,
|
||||
actions,
|
||||
help,
|
||||
);
|
||||
|
||||
screen.cleanup = () => {
|
||||
timers.forEach((timerId) => window.clearTimeout(timerId));
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
99
shine-UI/js/pages/key-storage-view.js
Normal file
99
shine-UI/js/pages/key-storage-view.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { authorizeSession, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const rootToggle = document.createElement('input');
|
||||
rootToggle.type = 'checkbox';
|
||||
rootToggle.checked = state.keyStorage.saveRoot;
|
||||
rootToggle.addEventListener('change', () => {
|
||||
state.keyStorage.saveRoot = rootToggle.checked;
|
||||
if (rootToggle.checked) {
|
||||
window.alert('Мы советуем не сохранять главный ключ на устройстве, он используется только для смены паролей и основных настроек.');
|
||||
}
|
||||
});
|
||||
|
||||
const blockchainToggle = document.createElement('input');
|
||||
blockchainToggle.type = 'checkbox';
|
||||
blockchainToggle.checked = state.keyStorage.saveBlockchain;
|
||||
blockchainToggle.addEventListener('change', () => {
|
||||
state.keyStorage.saveBlockchain = blockchainToggle.checked;
|
||||
});
|
||||
|
||||
const deviceToggle = document.createElement('input');
|
||||
deviceToggle.type = 'checkbox';
|
||||
deviceToggle.checked = true;
|
||||
deviceToggle.disabled = true;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="key-card stack">
|
||||
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
|
||||
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
|
||||
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
|
||||
<input class="input" type="text" value="${state.keyStorage.deviceKey}" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.children[0].querySelector('label').prepend(rootToggle);
|
||||
card.children[1].querySelector('label').prepend(blockchainToggle);
|
||||
card.children[2].querySelector('label').prepend(deviceToggle);
|
||||
|
||||
const rootInput = card.children[0].querySelector('.input');
|
||||
rootInput.addEventListener('input', () => {
|
||||
state.keyStorage.rootKey = rootInput.value;
|
||||
});
|
||||
|
||||
const blockchainInput = card.children[1].querySelector('.input');
|
||||
blockchainInput.addEventListener('input', () => {
|
||||
state.keyStorage.blockchainKey = blockchainInput.value;
|
||||
});
|
||||
|
||||
const deviceInput = card.children[2].querySelector('.input');
|
||||
deviceInput.addEventListener('input', () => {
|
||||
state.keyStorage.deviceKey = deviceInput.value;
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('login-password-view'));
|
||||
|
||||
const okButton = document.createElement('button');
|
||||
okButton.className = 'primary-btn';
|
||||
okButton.type = 'button';
|
||||
okButton.textContent = 'OK';
|
||||
okButton.addEventListener('click', () => {
|
||||
authorizeSession();
|
||||
navigate('profile-view');
|
||||
});
|
||||
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Какие ключи сохранить',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
43
shine-UI/js/pages/language-view.js
Normal file
43
shine-UI/js/pages/language-view.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Язык',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
||||
const selected = card.querySelector('input[name="language"]:checked');
|
||||
if (selected) {
|
||||
state.entrySettings.language = selected.value;
|
||||
}
|
||||
navigate('settings-view');
|
||||
});
|
||||
|
||||
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
screen.append(card, actions);
|
||||
return screen;
|
||||
}
|
||||
67
shine-UI/js/pages/login-camera-view.js
Normal file
67
shine-UI/js/pages/login-camera-view.js
Normal file
@ -0,0 +1,67 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'camera-shell';
|
||||
frame.innerHTML = `
|
||||
<video class="camera-video" autoplay playsinline muted></video>
|
||||
<div class="camera-frame"></div>
|
||||
<div class="camera-hint">Наведите QR-код в рамку</div>
|
||||
`;
|
||||
|
||||
const video = frame.querySelector('video');
|
||||
let stream = null;
|
||||
|
||||
const stopCamera = () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (navigator.mediaDevices?.getUserMedia) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
||||
.then((nextStream) => {
|
||||
stream = nextStream;
|
||||
video.srcObject = nextStream;
|
||||
})
|
||||
.catch(() => {
|
||||
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Не удалось открыть камеру. Проверьте разрешения браузера.</div>');
|
||||
});
|
||||
} else {
|
||||
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Камера не поддерживается в этом браузере.</div>');
|
||||
}
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => {
|
||||
stopCamera();
|
||||
navigate('login-view');
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти по камере',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
stopCamera();
|
||||
navigate('login-view');
|
||||
},
|
||||
},
|
||||
}),
|
||||
frame,
|
||||
backButton,
|
||||
);
|
||||
|
||||
screen.cleanup = stopCamera;
|
||||
return screen;
|
||||
}
|
||||
105
shine-UI/js/pages/login-password-view.js
Normal file
105
shine-UI/js/pages/login-password-view.js
Normal file
@ -0,0 +1,105 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
state,
|
||||
} from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.value = state.loginDraft.login;
|
||||
loginInput.placeholder = 'Введите логин';
|
||||
|
||||
const passwordInput = document.createElement('input');
|
||||
passwordInput.className = 'input';
|
||||
passwordInput.type = 'password';
|
||||
passwordInput.value = state.loginDraft.password;
|
||||
passwordInput.placeholder = 'Введите пароль';
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
|
||||
|
||||
form.innerHTML = `
|
||||
<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[1].append(passwordInput);
|
||||
form.append(hint);
|
||||
|
||||
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('login-view'));
|
||||
|
||||
const enterButton = document.createElement('button');
|
||||
enterButton.className = 'primary-btn';
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
state.loginDraft.login = loginInput.value.trim();
|
||||
state.loginDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.loginDraft.login || !state.loginDraft.password) {
|
||||
window.alert('Введите логин и пароль');
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
enterButton.disabled = true;
|
||||
enterButton.textContent = 'Входим...';
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
|
||||
state.registrationDraft.flowType = 'login';
|
||||
state.registrationDraft.login = result.login;
|
||||
state.registrationDraft.password = state.loginDraft.password;
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
enterButton.textContent = 'Войти';
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(backButton, enterButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти по логину',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
72
shine-UI/js/pages/login-view.js
Normal file
72
shine-UI/js/pages/login-view.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
|
||||
|
||||
function createQrCode() {
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 100 100');
|
||||
svg.classList.add('qr-code');
|
||||
|
||||
const cells = [
|
||||
[6, 6, 22, 22], [72, 6, 22, 22], [6, 72, 22, 22], [14, 14, 6, 6], [80, 14, 6, 6], [14, 80, 6, 6],
|
||||
[38, 12, 8, 8], [52, 12, 8, 8], [38, 26, 8, 8], [52, 26, 8, 8], [32, 40, 10, 10], [48, 40, 10, 10],
|
||||
[64, 40, 10, 10], [40, 56, 8, 8], [56, 56, 8, 8], [72, 56, 8, 8], [32, 72, 8, 8], [48, 72, 8, 8],
|
||||
[64, 72, 8, 8], [48, 86, 8, 8],
|
||||
];
|
||||
|
||||
cells.forEach(([x, y, width, height]) => {
|
||||
const rect = document.createElementNS(svgNS, 'rect');
|
||||
rect.setAttribute('x', x);
|
||||
rect.setAttribute('y', y);
|
||||
rect.setAttribute('width', width);
|
||||
rect.setAttribute('height', height);
|
||||
rect.setAttribute('rx', '2');
|
||||
svg.append(rect);
|
||||
});
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const qrCard = document.createElement('div');
|
||||
qrCard.className = 'card stack qr-card';
|
||||
qrCard.append(createQrCode());
|
||||
|
||||
const cameraButton = document.createElement('button');
|
||||
cameraButton.className = 'primary-btn';
|
||||
cameraButton.type = 'button';
|
||||
cameraButton.textContent = 'Войти по камере';
|
||||
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
|
||||
|
||||
const loginButton = document.createElement('button');
|
||||
loginButton.className = 'ghost-btn';
|
||||
loginButton.type = 'button';
|
||||
loginButton.textContent = 'Войти по логину';
|
||||
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions';
|
||||
actions.append(cameraButton, loginButton);
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
qrCard,
|
||||
actions,
|
||||
backButton,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
42
shine-UI/js/pages/messages-list.js
Normal file
42
shine-UI/js/pages/messages-list.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { directMessages } from '../mock-data.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Личные сообщения',
|
||||
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
|
||||
}),
|
||||
);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack';
|
||||
|
||||
directMessages.forEach((item) => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${item.initials}</div>
|
||||
<div>
|
||||
<div class="row" style="justify-content:flex-start; gap:8px;">
|
||||
<strong>${item.name}</strong>
|
||||
</div>
|
||||
<p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
|
||||
</div>
|
||||
<div style="display:grid; justify-items:end; gap:6px;">
|
||||
<span class="meta-muted">${item.time}</span>
|
||||
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', () => navigate(`chat-view/${item.id}`));
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
screen.append(list);
|
||||
return screen;
|
||||
}
|
||||
77
shine-UI/js/pages/network-view.js
Normal file
77
shine-UI/js/pages/network-view.js
Normal file
@ -0,0 +1,77 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { networkGraph } from '../mock-data.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
function toPoint(v) {
|
||||
return `${v.x}%`;
|
||||
}
|
||||
|
||||
function showHelpModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="network-help-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">Справка по схеме связей</h3>
|
||||
<p class="meta-muted">В центре находишься ты.</p>
|
||||
<p class="meta-muted">Рядом показаны друзья первого уровня.</p>
|
||||
<p class="meta-muted">Далее могут существовать друзья второго уровня.</p>
|
||||
<p class="meta-muted">При одном нажатии на узел можно показать его связи.</p>
|
||||
<p class="meta-muted">При двойном нажатии узел может переместиться в центр.</p>
|
||||
<p class="meta-muted">При долгом удержании может открываться меню действий.</p>
|
||||
<p class="meta-muted">Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.</p>
|
||||
<button class="primary-btn" id="close-network-help">Понятно</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
root.querySelector('#close-network-help').addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
|
||||
});
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'network-board';
|
||||
|
||||
const lines = networkGraph.peers
|
||||
.map(
|
||||
(peer) =>
|
||||
`<line x1="${toPoint(networkGraph.center)}" y1="${networkGraph.center.y}%" x2="${peer.x}%" y2="${peer.y}%" stroke="rgba(125,170,255,0.55)" stroke-width="1.5"/>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
board.innerHTML = `<svg class="network-svg" viewBox="0 0 100 100" preserveAspectRatio="none">${lines}</svg>`;
|
||||
|
||||
const centerNode = document.createElement('div');
|
||||
centerNode.className = 'node center';
|
||||
centerNode.style.left = `${networkGraph.center.x}%`;
|
||||
centerNode.style.top = `${networkGraph.center.y}%`;
|
||||
centerNode.innerHTML = `<div class="node-dot">${networkGraph.center.initials}</div><div class="node-label">${networkGraph.center.name}</div>`;
|
||||
|
||||
board.append(centerNode);
|
||||
|
||||
networkGraph.peers.forEach((peer) => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'node';
|
||||
node.style.left = `${peer.x}%`;
|
||||
node.style.top = `${peer.y}%`;
|
||||
node.innerHTML = `<div class="node-dot">${peer.initials}</div><div class="node-label">${peer.name}</div>`;
|
||||
board.append(node);
|
||||
});
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.';
|
||||
|
||||
screen.append(header, board, note);
|
||||
return screen;
|
||||
}
|
||||
48
shine-UI/js/pages/notifications-view.js
Normal file
48
shine-UI/js/pages/notifications-view.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { notifications } from '../mock-data.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||
|
||||
function renderList(container) {
|
||||
const active = state.notificationsTab;
|
||||
const items = notifications[active] || [];
|
||||
container.innerHTML = '';
|
||||
|
||||
items.forEach((item) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `<strong>${item.title}</strong><p class="meta-muted">${item.text}</p><p class="meta-muted">${item.time}</p>`;
|
||||
container.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(renderHeader({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
tabs.innerHTML = `
|
||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||
`;
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack';
|
||||
renderList(list);
|
||||
|
||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.notificationsTab = btn.dataset.tab;
|
||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderList(list);
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
return screen;
|
||||
}
|
||||
107
shine-UI/js/pages/profile-view.js
Normal file
107
shine-UI/js/pages/profile-view.js
Normal file
@ -0,0 +1,107 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { profile } from '../mock-data.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const badgeHelp = {
|
||||
official: {
|
||||
title: 'Официальный аккаунт',
|
||||
text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
|
||||
},
|
||||
shine: {
|
||||
title: 'Сияющий',
|
||||
text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
|
||||
},
|
||||
};
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Профиль',
|
||||
rightActions: [
|
||||
{ label: 'Кошелёк', onClick: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', onClick: () => navigate('settings-view') },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="avatar large">${profile.avatarInitials}</div>
|
||||
<div class="stack" style="justify-items:end; text-align:right;">
|
||||
<button class="badge profile-badge-trigger" type="button" data-badge="official">✔ ${profile.badges[0]}</button>
|
||||
<button class="badge alt profile-badge-trigger" type="button" data-badge="shine">✨ ${profile.badges[1]}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="font-size:22px; margin-bottom:2px;">${profile.name}</h2>
|
||||
<p class="meta-muted">${profile.login}</p>
|
||||
</div>
|
||||
<div class="stack" style="gap:8px;">
|
||||
<div class="card" style="padding:10px;"><span class="meta-muted">Телефон:</span> ${profile.phone}</div>
|
||||
<div class="card" style="padding:10px;"><span class="meta-muted">Адрес:</span> ${profile.address}</div>
|
||||
<div class="card" style="padding:10px;"><span class="meta-muted">Email:</span> ${profile.email}</div>
|
||||
<div class="card" style="padding:10px;"><span class="meta-muted">Соцсети:</span> ${profile.socials}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'profile-help-modal';
|
||||
modal.hidden = true;
|
||||
modal.innerHTML = `
|
||||
<div class="profile-help-backdrop" data-close="true"></div>
|
||||
<div class="profile-help-dialog card" role="dialog" aria-modal="true" aria-labelledby="profile-help-title" tabindex="-1">
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div>
|
||||
<div class="meta-muted" style="margin-bottom:4px;">Управление функцией</div>
|
||||
<h3 id="profile-help-title" style="font-size:18px;"></h3>
|
||||
</div>
|
||||
<button class="icon-btn profile-help-close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<p class="profile-help-text"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const titleEl = modal.querySelector('#profile-help-title');
|
||||
const textEl = modal.querySelector('.profile-help-text');
|
||||
const dialogEl = modal.querySelector('.profile-help-dialog');
|
||||
|
||||
function closeModal() {
|
||||
modal.hidden = true;
|
||||
}
|
||||
|
||||
function openModal(type) {
|
||||
const content = badgeHelp[type];
|
||||
if (!content) return;
|
||||
|
||||
titleEl.textContent = content.title;
|
||||
textEl.textContent = content.text;
|
||||
modal.hidden = false;
|
||||
dialogEl.focus();
|
||||
}
|
||||
|
||||
card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
|
||||
button.addEventListener('click', () => openModal(button.dataset.badge));
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
modal.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(card, modal);
|
||||
return screen;
|
||||
}
|
||||
114
shine-UI/js/pages/register-view.js
Normal file
114
shine-UI/js/pages/register-view.js
Normal file
@ -0,0 +1,114 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { authService, clearAuthMessages, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.value = state.registrationDraft.login;
|
||||
loginInput.placeholder = 'Введите логин';
|
||||
|
||||
const passwordInput = document.createElement('input');
|
||||
passwordInput.className = 'input';
|
||||
passwordInput.type = 'password';
|
||||
passwordInput.value = state.registrationDraft.password;
|
||||
passwordInput.placeholder = 'Введите пароль';
|
||||
|
||||
const statusText = document.createElement('p');
|
||||
statusText.className = 'meta-muted';
|
||||
statusText.textContent = 'Проверка логина: не выполнена';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить логин';
|
||||
|
||||
async function runAvailabilityCheck() {
|
||||
const login = loginInput.value.trim();
|
||||
if (!login) {
|
||||
statusText.textContent = 'Введите логин';
|
||||
return false;
|
||||
}
|
||||
|
||||
checkButton.disabled = true;
|
||||
checkButton.textContent = 'Проверка...';
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const isFree = await authService.ensureLoginFree(login);
|
||||
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
|
||||
statusText.className = isFree ? 'is-available' : 'is-unavailable';
|
||||
return isFree;
|
||||
} catch (error) {
|
||||
statusText.textContent = error.message;
|
||||
statusText.className = 'is-unavailable';
|
||||
return false;
|
||||
} finally {
|
||||
checkButton.disabled = false;
|
||||
checkButton.textContent = 'Проверить логин';
|
||||
}
|
||||
}
|
||||
|
||||
checkButton.addEventListener('click', runAvailabilityCheck);
|
||||
|
||||
form.innerHTML = `
|
||||
<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[1].append(passwordInput);
|
||||
form.append(checkButton, statusText);
|
||||
|
||||
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 = 'Далее';
|
||||
nextButton.addEventListener('click', async () => {
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
window.alert('Выберите свободный логин');
|
||||
return;
|
||||
}
|
||||
|
||||
state.registrationDraft.login = loginInput.value.trim();
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.registrationDraft.password) {
|
||||
window.alert('Введите пароль');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('registration-payment-view');
|
||||
});
|
||||
|
||||
actions.append(backButton, nextButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Зарегистрироваться',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
140
shine-UI/js/pages/registration-keys-view.js
Normal file
140
shine-UI/js/pages/registration-keys-view.js
Normal file
@ -0,0 +1,140 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const isLoginFlow = state.registrationDraft.flowType === 'login';
|
||||
const normalizedLogin = (state.registrationDraft.login || '').trim();
|
||||
const displayLogin = normalizedLogin || '@new.user';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.className = 'auth-copy';
|
||||
title.textContent = isLoginFlow
|
||||
? `Вход выполнен для логина ${displayLogin}.`
|
||||
: `Отлично, логин ${displayLogin} зарегистрирован.`;
|
||||
|
||||
const question = document.createElement('p');
|
||||
question.className = 'auth-copy';
|
||||
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
|
||||
|
||||
const rootToggle = document.createElement('input');
|
||||
rootToggle.type = 'checkbox';
|
||||
rootToggle.checked = state.keyStorage.saveRoot;
|
||||
|
||||
const blockchainToggle = document.createElement('input');
|
||||
blockchainToggle.type = 'checkbox';
|
||||
blockchainToggle.checked = state.keyStorage.saveBlockchain;
|
||||
|
||||
const deviceToggle = document.createElement('input');
|
||||
deviceToggle.type = 'checkbox';
|
||||
deviceToggle.checked = true;
|
||||
deviceToggle.disabled = true;
|
||||
|
||||
const rootRow = document.createElement('label');
|
||||
rootRow.className = 'checkbox-row';
|
||||
rootRow.append(rootToggle, document.createTextNode('root key'));
|
||||
|
||||
const blockchainRow = document.createElement('label');
|
||||
blockchainRow.className = 'checkbox-row';
|
||||
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
|
||||
|
||||
const deviceRow = document.createElement('label');
|
||||
deviceRow.className = 'checkbox-row';
|
||||
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
|
||||
|
||||
card.append(title, question, rootRow, blockchainRow, deviceRow);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const okButton = document.createElement('button');
|
||||
okButton.className = 'primary-btn';
|
||||
okButton.type = 'button';
|
||||
okButton.textContent = 'OK';
|
||||
okButton.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
||||
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
||||
}
|
||||
|
||||
state.keyStorage.saveRoot = rootToggle.checked;
|
||||
state.keyStorage.saveBlockchain = blockchainToggle.checked;
|
||||
|
||||
await authService.persistSelectedKeys(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.storagePwd,
|
||||
state.registrationDraft.pendingKeyBundle,
|
||||
{
|
||||
saveRoot: state.keyStorage.saveRoot,
|
||||
saveBlockchain: state.keyStorage.saveBlockchain,
|
||||
},
|
||||
);
|
||||
await authService.persistSessionMaterial(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.pendingSessionMaterial,
|
||||
);
|
||||
|
||||
if (!state.keyStorage.saveRoot && state.registrationDraft.pendingKeyBundle) {
|
||||
state.registrationDraft.pendingKeyBundle.rootPair = null;
|
||||
}
|
||||
if (!state.keyStorage.saveBlockchain && state.registrationDraft.pendingKeyBundle) {
|
||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||
}
|
||||
|
||||
authorizeSession({
|
||||
login: state.registrationDraft.login,
|
||||
sessionId: state.registrationDraft.sessionId,
|
||||
storagePwd: state.registrationDraft.storagePwd,
|
||||
});
|
||||
|
||||
state.loginDraft.login = state.registrationDraft.login;
|
||||
state.loginDraft.password = '';
|
||||
state.registrationDraft.flowType = '';
|
||||
state.registrationDraft.password = '';
|
||||
state.registrationDraft.storagePwd = '';
|
||||
state.registrationDraft.sessionId = '';
|
||||
state.registrationDraft.pendingKeyBundle = null;
|
||||
state.registrationDraft.pendingSessionMaterial = null;
|
||||
|
||||
await refreshSessions();
|
||||
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
|
||||
navigate('profile-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Сохранение ключей',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
120
shine-UI/js/pages/registration-payment-view.js
Normal file
120
shine-UI/js/pages/registration-payment-view.js
Normal file
@ -0,0 +1,120 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import {
|
||||
authService,
|
||||
refreshRegistrationBalance,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const walletValue = document.createElement('input');
|
||||
walletValue.className = 'input';
|
||||
walletValue.type = 'text';
|
||||
walletValue.value = state.registrationPayment.walletAddress;
|
||||
walletValue.addEventListener('input', () => {
|
||||
state.registrationPayment.walletAddress = walletValue.value;
|
||||
});
|
||||
|
||||
const walletRow = document.createElement('div');
|
||||
walletRow.className = 'inline-input-row';
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'ghost-btn';
|
||||
copyButton.type = 'button';
|
||||
copyButton.textContent = 'Скопировать номер';
|
||||
copyButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(walletValue.value);
|
||||
copyButton.textContent = 'Скопировано';
|
||||
window.setTimeout(() => {
|
||||
copyButton.textContent = 'Скопировать номер';
|
||||
}, 1500);
|
||||
} catch {
|
||||
window.alert('Не удалось скопировать номер кошелька.');
|
||||
}
|
||||
});
|
||||
|
||||
walletRow.append(walletValue, copyButton);
|
||||
|
||||
const balanceRow = document.createElement('div');
|
||||
balanceRow.className = 'row wrap-row';
|
||||
|
||||
const balanceValue = document.createElement('strong');
|
||||
balanceValue.textContent = `${state.registrationPayment.balanceSOL} SOL`;
|
||||
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.className = 'square-btn';
|
||||
refreshButton.type = 'button';
|
||||
refreshButton.textContent = '↻';
|
||||
refreshButton.title = 'Обновить';
|
||||
refreshButton.addEventListener('click', () => {
|
||||
balanceValue.textContent = `${refreshRegistrationBalance()} SOL`;
|
||||
});
|
||||
|
||||
balanceRow.append(balanceValue, refreshButton);
|
||||
|
||||
const topupButton = document.createElement('button');
|
||||
topupButton.className = 'ghost-btn';
|
||||
topupButton.type = 'button';
|
||||
topupButton.textContent = 'Пополнить счет';
|
||||
topupButton.addEventListener('click', () => navigate('topup-view'));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.className = 'primary-btn';
|
||||
submitButton.type = 'button';
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
submitButton.addEventListener('click', async () => {
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Регистрация...';
|
||||
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
||||
state.registrationDraft.flowType = 'registration';
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
|
||||
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
|
||||
window.alert('Отлично, вы зарегистрировались');
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
}
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
<p class="auth-copy">Для регистрации в Solana нужно заплатить 0,01 SOL (примерно 1 доллар).</p>
|
||||
<label class="stack"><span class="field-label">Номер кошелька</span></label>
|
||||
<div class="stack">
|
||||
<span class="field-label">Баланс</span>
|
||||
</div>
|
||||
`;
|
||||
card.children[1].append(walletRow);
|
||||
card.children[2].append(balanceRow);
|
||||
card.append(topupButton, submitButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Оплата регистрации',
|
||||
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||
}),
|
||||
card,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
143
shine-UI/js/pages/server-settings-view.js
Normal file
143
shine-UI/js/pages/server-settings-view.js
Normal file
@ -0,0 +1,143 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServer', label: 'Адрес сервера Сияние' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const draft = {
|
||||
language: state.entrySettings.language,
|
||||
solanaServer: state.entrySettings.solanaServer,
|
||||
shineServer: state.entrySettings.shineServer,
|
||||
arweaveServer: state.entrySettings.arweaveServer,
|
||||
statuses: { ...state.entrySettings.statuses },
|
||||
};
|
||||
|
||||
const timers = new Map();
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card stack';
|
||||
|
||||
SERVER_FIELDS.forEach((field) => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'stack';
|
||||
|
||||
const title = document.createElement('label');
|
||||
title.className = 'field-label';
|
||||
title.textContent = field.label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.value = draft[field.key];
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'status-line';
|
||||
|
||||
const applyStatus = (value) => {
|
||||
draft.statuses[field.key] = value;
|
||||
checkButton.classList.remove('is-available', 'is-unavailable');
|
||||
status.classList.remove('is-available', 'is-unavailable');
|
||||
|
||||
if (value === 'available') {
|
||||
status.textContent = 'Доступен';
|
||||
checkButton.classList.add('is-available');
|
||||
status.classList.add('is-available');
|
||||
} else if (value === 'unavailable') {
|
||||
status.textContent = 'Недоступен';
|
||||
checkButton.classList.add('is-unavailable');
|
||||
status.classList.add('is-unavailable');
|
||||
} else {
|
||||
status.textContent = 'Статус не проверен';
|
||||
}
|
||||
};
|
||||
|
||||
const runCheck = () => {
|
||||
draft[field.key] = input.value.trim();
|
||||
applyStatus(checkServerAvailability(input.value));
|
||||
};
|
||||
|
||||
applyStatus(draft.statuses[field.key]);
|
||||
|
||||
checkButton.addEventListener('click', runCheck);
|
||||
input.addEventListener('input', () => {
|
||||
draft[field.key] = input.value;
|
||||
applyStatus('idle');
|
||||
window.clearTimeout(timers.get(field.key));
|
||||
timers.set(field.key, window.setTimeout(runCheck, 3000));
|
||||
});
|
||||
input.addEventListener('blur', runCheck);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
runCheck();
|
||||
}
|
||||
});
|
||||
|
||||
controls.append(checkButton, status);
|
||||
block.append(title, input, controls);
|
||||
body.append(block);
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', () => {
|
||||
saveEntrySettings(draft);
|
||||
navigate('settings-view');
|
||||
});
|
||||
|
||||
actions.append(cancelButton, saveButton);
|
||||
|
||||
const help = document.createElement('button');
|
||||
help.className = 'help-fab';
|
||||
help.type = 'button';
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
window.alert(
|
||||
'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
|
||||
);
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки серверов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
body,
|
||||
actions,
|
||||
help,
|
||||
);
|
||||
|
||||
screen.cleanup = () => {
|
||||
timers.forEach((timerId) => window.clearTimeout(timerId));
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
30
shine-UI/js/pages/settings-view.js
Normal file
30
shine-UI/js/pages/settings-view.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
127
shine-UI/js/pages/show-keys-view.js
Normal file
127
shine-UI/js/pages/show-keys-view.js
Normal file
@ -0,0 +1,127 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const visible = {
|
||||
root: false,
|
||||
blockchain: false,
|
||||
device: false,
|
||||
};
|
||||
|
||||
const keys = {
|
||||
root: '',
|
||||
blockchain: '',
|
||||
device: '',
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Показать ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
status.textContent = 'Загружаем сохранённые ключи...';
|
||||
card.append(status);
|
||||
|
||||
const renderField = (id, label) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'key-card stack';
|
||||
row.innerHTML = `
|
||||
<div class="row">
|
||||
<span class="field-label">${label}</span>
|
||||
<button class="icon-btn small-btn" type="button" data-toggle="${id}">Показать</button>
|
||||
</div>
|
||||
<div class="key-value" data-value="${id}">*****</div>
|
||||
`;
|
||||
return row;
|
||||
};
|
||||
|
||||
card.append(
|
||||
renderField('root', 'root key'),
|
||||
renderField('blockchain', 'blockchain.key'),
|
||||
renderField('device', 'device key'),
|
||||
);
|
||||
|
||||
const setMissingState = (id) => {
|
||||
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
||||
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
||||
valueEl.textContent = 'нет данных';
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = 'Нет';
|
||||
};
|
||||
|
||||
const updateField = (id) => {
|
||||
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
||||
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
||||
if (!keys[id]) {
|
||||
setMissingState(id);
|
||||
return;
|
||||
}
|
||||
valueEl.textContent = visible[id] ? keys[id] : '*****';
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать';
|
||||
};
|
||||
|
||||
card.querySelectorAll('[data-toggle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const { toggle } = button.dataset;
|
||||
if (!keys[toggle]) return;
|
||||
visible[toggle] = !visible[toggle];
|
||||
updateField(toggle);
|
||||
});
|
||||
});
|
||||
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'ghost-btn';
|
||||
closeButton.type = 'button';
|
||||
closeButton.textContent = 'Назад';
|
||||
closeButton.addEventListener('click', () => navigate('device-view'));
|
||||
actions.append(closeButton);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (!state.session.login || !state.session.storagePwdInMemory) {
|
||||
throw new Error('Нет активной сессии для чтения ключей');
|
||||
}
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(
|
||||
state.session.login,
|
||||
state.session.storagePwdInMemory,
|
||||
);
|
||||
|
||||
keys.root = savedKeys.rootKey || '';
|
||||
keys.blockchain = savedKeys.blockchainKey || '';
|
||||
keys.device = savedKeys.deviceKey || '';
|
||||
|
||||
if (keys.root || keys.blockchain || keys.device) {
|
||||
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
|
||||
} else {
|
||||
status.textContent = 'На этом устройстве нет сохранённых ключей.';
|
||||
}
|
||||
} catch (error) {
|
||||
status.textContent = 'На этом устройстве нет сохранённых ключей.';
|
||||
}
|
||||
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
})();
|
||||
|
||||
screen.append(card, actions);
|
||||
return screen;
|
||||
}
|
||||
53
shine-UI/js/pages/start-view.js
Normal file
53
shine-UI/js/pages/start-view.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { clearStartHint, state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'auth-screen stack';
|
||||
|
||||
const logo = document.createElement('img');
|
||||
logo.className = 'auth-logo';
|
||||
logo.src = './img/logo.jpg';
|
||||
logo.alt = 'Логотип Сияние';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'auth-brand';
|
||||
title.textContent = 'Сияние';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions';
|
||||
|
||||
const loginButton = document.createElement('button');
|
||||
loginButton.className = 'primary-btn';
|
||||
loginButton.type = 'button';
|
||||
loginButton.textContent = 'Войти';
|
||||
loginButton.addEventListener('click', () => navigate('login-view'));
|
||||
|
||||
const registerButton = document.createElement('button');
|
||||
registerButton.className = 'ghost-btn';
|
||||
registerButton.type = 'button';
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'ghost-btn';
|
||||
settingsButton.type = 'button';
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, settingsButton);
|
||||
|
||||
screen.append(logo, title);
|
||||
|
||||
if (state.startHint) {
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'card auth-status-card';
|
||||
notice.textContent = state.startHint;
|
||||
screen.append(notice);
|
||||
clearStartHint();
|
||||
}
|
||||
|
||||
screen.append(actions);
|
||||
return screen;
|
||||
}
|
||||
84
shine-UI/js/pages/topup-view.js
Normal file
84
shine-UI/js/pages/topup-view.js
Normal file
@ -0,0 +1,84 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { state } from '../state.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
|
||||
|
||||
const BUY_LINK = 'https://www.moonpay.com/buy/sol';
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const walletValue = document.createElement('input');
|
||||
walletValue.className = 'input';
|
||||
walletValue.type = 'text';
|
||||
walletValue.value = state.registrationPayment.walletAddress;
|
||||
walletValue.readOnly = true;
|
||||
walletValue.style.fontSize = '13px';
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'ghost-btn';
|
||||
copyButton.type = 'button';
|
||||
copyButton.textContent = 'Скопировать';
|
||||
copyButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(walletValue.value);
|
||||
copyButton.textContent = 'Скопировано';
|
||||
window.setTimeout(() => {
|
||||
copyButton.textContent = 'Скопировать';
|
||||
}, 1500);
|
||||
} catch {
|
||||
window.alert('Не удалось скопировать номер кошелька.');
|
||||
}
|
||||
});
|
||||
|
||||
const walletRow = document.createElement('div');
|
||||
walletRow.className = 'inline-input-row';
|
||||
walletRow.append(walletValue, copyButton);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<p class="auth-copy">Для пополнения счета скопируйте номер кошелька.</p>
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="meta-muted">1. Пополните через любое свое приложение, используя этот кошелек в сети Solana.</p>
|
||||
<p class="meta-muted">2. Либо откройте страницу для покупки SOL.</p>
|
||||
<p class="meta-muted">3. Либо используйте кнопку «Тестовое пополнение» (работает в тестовой Solana).</p>
|
||||
</div>
|
||||
<a class="link-card" href="${BUY_LINK}" target="_blank" rel="noreferrer">Открыть страницу покупки SOL</a>
|
||||
<div class="card stack" style="padding:12px; max-width:320px;">
|
||||
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения</div>
|
||||
</div>
|
||||
`;
|
||||
card.children[3].append(walletRow);
|
||||
|
||||
const testButton = document.createElement('button');
|
||||
testButton.className = 'ghost-btn';
|
||||
testButton.type = 'button';
|
||||
testButton.textContent = 'Тестовое пополнение';
|
||||
testButton.addEventListener('click', () => {
|
||||
state.registrationPayment.balanceSOL = '0.0250';
|
||||
window.alert('Тестовое пополнение выполнено. Баланс обновлён.');
|
||||
});
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'primary-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('registration-payment-view'));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.append(testButton, backButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Пополнение счета',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
78
shine-UI/js/pages/wallet-view.js
Normal file
78
shine-UI/js/pages/wallet-view.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260330210201';
|
||||
import { wallet } from '../mock-data.js?v=20260330210201';
|
||||
|
||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
let statusText = 'Данные демонстрационные';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
|
||||
const updateStatus = (text) => {
|
||||
statusText = text;
|
||||
status.textContent = statusText;
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Кошелёк',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
})
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<div>
|
||||
<p class="meta-muted">Баланс</p>
|
||||
<h2 style="font-size:30px;">${wallet.balanceSOL} SOL</h2>
|
||||
<p class="meta-muted">Обновлено: ${wallet.updatedAt}</p>
|
||||
</div>
|
||||
<div class="card" style="padding:10px;">
|
||||
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес</p>
|
||||
<p style="font-size:13px; line-height:1.4; word-break:break-all;">${wallet.publicAddress}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<div class="row">
|
||||
<button class="text-btn" id="copy-address">Копировать адрес</button>
|
||||
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary-btn" id="send-sol" style="width:100%;">Перевести</button>
|
||||
<button class="primary-btn" id="topup-sol" style="width:100%;">Пополнить</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
actions.querySelector('#copy-address').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wallet.publicAddress);
|
||||
updateStatus('Адрес скопирован в буфер обмена');
|
||||
} catch {
|
||||
updateStatus('Не удалось скопировать в этом браузере');
|
||||
}
|
||||
});
|
||||
|
||||
actions.querySelector('#refresh-balance').addEventListener('click', () => {
|
||||
updateStatus(`Баланс обновлен: ${wallet.balanceSOL} SOL`);
|
||||
});
|
||||
|
||||
actions.querySelector('#send-sol').addEventListener('click', () => {
|
||||
updateStatus('Демо-функция: перевод будет добавлен позже');
|
||||
});
|
||||
|
||||
actions.querySelector('#topup-sol').addEventListener('click', () => {
|
||||
updateStatus('Демо-функция: пополнение будет добавлено позже');
|
||||
});
|
||||
|
||||
updateStatus(statusText);
|
||||
|
||||
screen.append(card, actions, status);
|
||||
return screen;
|
||||
}
|
||||
62
shine-UI/js/router.js
Normal file
62
shine-UI/js/router.js
Normal file
@ -0,0 +1,62 @@
|
||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
||||
|
||||
export const PRE_AUTH_PAGES = [
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
'register-view',
|
||||
'registration-payment-view',
|
||||
'registration-keys-view',
|
||||
'topup-view',
|
||||
'login-view',
|
||||
'login-camera-view',
|
||||
'login-password-view',
|
||||
'key-storage-view',
|
||||
];
|
||||
|
||||
export function getRoute() {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
if (!raw) {
|
||||
return { pageId: '', params: {} };
|
||||
}
|
||||
|
||||
const [pageId, dynamicId] = raw.split('/');
|
||||
|
||||
if (pageId === 'chat-view') {
|
||||
return { pageId, params: { chatId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channel-view') {
|
||||
return { pageId, params: { channelId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'device-session-view') {
|
||||
return { pageId, params: { sessionId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
return { pageId, params: {} };
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = `#/${path}`;
|
||||
}
|
||||
|
||||
export function resolveToolbarActive(pageId) {
|
||||
if (ROOT_PAGES.includes(pageId)) return pageId;
|
||||
if (
|
||||
pageId === 'wallet-view' ||
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
pageId === 'device-qr-view' ||
|
||||
pageId === 'device-camera-view' ||
|
||||
pageId === 'show-keys-view' ||
|
||||
pageId === 'device-session-view' ||
|
||||
pageId === 'language-view'
|
||||
) {
|
||||
return 'profile-view';
|
||||
}
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
250
shine-UI/js/services/auth-service.js
Normal file
250
shine-UI/js/services/auth-service.js
Normal file
@ -0,0 +1,250 @@
|
||||
import { WsJsonClient } from './ws-client.js?v=20260330210201';
|
||||
import {
|
||||
deriveEd25519FromPassword,
|
||||
exportEd25519PublicKeyB64,
|
||||
exportPkcs8B64,
|
||||
generateEd25519Pair,
|
||||
importPkcs8Ed25519,
|
||||
randomBase64,
|
||||
signBase64,
|
||||
} from './crypto-utils.js?v=20260330210201';
|
||||
import { loadSessionMaterial, saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260330210201';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
|
||||
function normalizeServerUrl(url) {
|
||||
const value = (url || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function opError(op, response) {
|
||||
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
|
||||
const code = response?.payload?.code || response?.code || 'UNKNOWN';
|
||||
const error = new Error(`${op}: ${message} (${code})`);
|
||||
error.op = op;
|
||||
error.code = code;
|
||||
error.status = response?.status || 0;
|
||||
return error;
|
||||
}
|
||||
|
||||
function makeClientInfo() {
|
||||
const ua = navigator.userAgent || 'unknown';
|
||||
return ua.slice(0, 50);
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
const normalized = normalizeServerUrl(serverUrl);
|
||||
if (normalized === this.serverUrl) return;
|
||||
this.ws.close();
|
||||
this.serverUrl = normalized;
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
}
|
||||
|
||||
async getUser(login) {
|
||||
const response = await this.ws.request('GetUser', { login });
|
||||
if (response.status !== 200) throw opError('GetUser', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async ensureLoginFree(login) {
|
||||
const payload = await this.getUser(login);
|
||||
return payload.exists !== true;
|
||||
}
|
||||
|
||||
async derivePasswordKeyBundle(password) {
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
const rootPair = await deriveEd25519FromPassword(password, 'root.key');
|
||||
const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key');
|
||||
const devicePair = await deriveEd25519FromPassword(password, 'dev.key');
|
||||
return { rootPair, blockchainPair, devicePair };
|
||||
}
|
||||
|
||||
async createAuthSession(login, keyBundle) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
|
||||
const sessionPair = await generateEd25519Pair();
|
||||
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
|
||||
const sessionKey = `ed25519/${sessionKeyPub}`;
|
||||
const storagePwd = randomBase64(32);
|
||||
|
||||
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
|
||||
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
||||
|
||||
const authNonce = challengeResp?.payload?.authNonce;
|
||||
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
|
||||
|
||||
const timeMs = Date.now();
|
||||
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
||||
const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);
|
||||
|
||||
const createResp = await this.ws.request('CreateAuthSession', {
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
sessionKey,
|
||||
timeMs,
|
||||
authNonce,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
signatureB64,
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
||||
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
sessionMaterial: {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async registerUser(login, password) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
|
||||
const isFree = await this.ensureLoginFree(cleanLogin);
|
||||
if (!isFree) throw new Error('Этот логин уже занят');
|
||||
|
||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
||||
|
||||
const addResp = await this.ws.request('AddUser', {
|
||||
login: cleanLogin,
|
||||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||||
solanaKey: keyBundle.rootPair.publicKeyB64,
|
||||
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
bchLimit: 1000000,
|
||||
});
|
||||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||||
|
||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||
return { ...session, keyBundle };
|
||||
}
|
||||
|
||||
async createSessionForExistingUser(login, password) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
if (!user.exists) throw new Error('Пользователь не найден');
|
||||
|
||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||
return { ...session, keyBundle };
|
||||
}
|
||||
|
||||
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
||||
const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
|
||||
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
|
||||
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
|
||||
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
||||
}
|
||||
|
||||
async persistSessionMaterial(login, sessionMaterial) {
|
||||
await saveSessionMaterial(login, sessionMaterial);
|
||||
}
|
||||
|
||||
|
||||
async resumeSession(login, preferredSessionId = '') {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Нет login для авто-входа');
|
||||
|
||||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного ключа сессии');
|
||||
}
|
||||
|
||||
const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
|
||||
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||||
|
||||
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
|
||||
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
|
||||
|
||||
const nonce = challengeResp?.payload?.nonce;
|
||||
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
|
||||
|
||||
const timeMs = Date.now();
|
||||
const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
|
||||
const signatureB64 = await signBase64(privateKey, preimage);
|
||||
|
||||
const loginResp = await this.ws.request('SessionLogin', {
|
||||
sessionId: targetSessionId,
|
||||
sessionKey: sessionMaterial.sessionKey,
|
||||
timeMs,
|
||||
signatureB64,
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
|
||||
|
||||
const storagePwd = loginResp?.payload?.storagePwd;
|
||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
sessionId: targetSessionId,
|
||||
storagePwd,
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions() {
|
||||
const response = await this.ws.request('ListSessions', {});
|
||||
if (response.status !== 200) throw opError('ListSessions', response);
|
||||
return response?.payload?.sessions || [];
|
||||
}
|
||||
|
||||
async closeSession(sessionId) {
|
||||
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
||||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
||||
}
|
||||
|
||||
async listSubscriptionsFeed(login, limit = 200) {
|
||||
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
||||
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getChannelMessages(channel, limit = 200, sort = 'asc') {
|
||||
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
|
||||
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
|
||||
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
|
||||
if (response.status !== 200) throw opError('GetMessageThread', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async reportClientError(details) {
|
||||
try {
|
||||
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
||||
return response?.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
105
shine-UI/js/services/client-error-reporter.js
Normal file
105
shine-UI/js/services/client-error-reporter.js
Normal file
@ -0,0 +1,105 @@
|
||||
const MAX_CONTEXT_LEN = 2000;
|
||||
const RECENT_WINDOW_MS = 5000;
|
||||
|
||||
let transport = null;
|
||||
let transportDepth = 0;
|
||||
const recentFingerprints = new Map();
|
||||
|
||||
function nowTs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function cleanString(value, maxLen = 1000) {
|
||||
if (value == null) return '';
|
||||
const normalized = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxLen - 3))}...`;
|
||||
}
|
||||
|
||||
function stringifyContext(context) {
|
||||
if (context == null) return '';
|
||||
try {
|
||||
const raw = JSON.stringify(context);
|
||||
if (!raw) return '';
|
||||
if (raw.length <= MAX_CONTEXT_LEN) return raw;
|
||||
return `${raw.slice(0, MAX_CONTEXT_LEN - 3)}...`;
|
||||
} catch (error) {
|
||||
return cleanString(`context_json_error:${error?.message || error}`, MAX_CONTEXT_LEN);
|
||||
}
|
||||
}
|
||||
|
||||
function makeFingerprint(payload) {
|
||||
return [
|
||||
payload.kind,
|
||||
payload.message,
|
||||
payload.sourceUrl,
|
||||
payload.lineNumber,
|
||||
payload.columnNumber,
|
||||
payload.requestOp,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function isDuplicate(fingerprint) {
|
||||
const ts = nowTs();
|
||||
const prev = recentFingerprints.get(fingerprint);
|
||||
recentFingerprints.set(fingerprint, ts);
|
||||
|
||||
for (const [key, time] of recentFingerprints.entries()) {
|
||||
if (ts - time > RECENT_WINDOW_MS) {
|
||||
recentFingerprints.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return prev != null && ts - prev < RECENT_WINDOW_MS;
|
||||
}
|
||||
|
||||
function buildPayload(details = {}) {
|
||||
return {
|
||||
kind: cleanString(details.kind || 'client_error', 64),
|
||||
message: cleanString(details.message || details.reason || 'Unknown client error', 500),
|
||||
stack: cleanString(details.stack || details.error?.stack || '', 8000),
|
||||
sourceUrl: cleanString(details.sourceUrl || details.fileName || '', 240),
|
||||
lineNumber: Number.isFinite(details.lineNumber) ? details.lineNumber : null,
|
||||
columnNumber: Number.isFinite(details.columnNumber) ? details.columnNumber : null,
|
||||
route: cleanString(details.route || window.location?.hash || '', 200),
|
||||
href: cleanString(details.href || window.location?.href || '', 240),
|
||||
userAgent: cleanString(details.userAgent || navigator.userAgent || '', 240),
|
||||
clientTs: Number.isFinite(details.clientTs) ? details.clientTs : nowTs(),
|
||||
requestOp: cleanString(details.requestOp || '', 64),
|
||||
requestIdRef: cleanString(details.requestIdRef || '', 128),
|
||||
contextJson: stringifyContext({
|
||||
title: document.title || '',
|
||||
pageVisibility: document.visibilityState || '',
|
||||
...details.context,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function setClientErrorTransport(fn) {
|
||||
transport = typeof fn === 'function' ? fn : null;
|
||||
}
|
||||
|
||||
export async function captureClientError(details = {}) {
|
||||
const payload = buildPayload(details);
|
||||
if (!payload.message) return false;
|
||||
|
||||
const fingerprint = details.dedupeKey || makeFingerprint(payload);
|
||||
if (isDuplicate(fingerprint)) return false;
|
||||
|
||||
console.error('[client-error]', payload.kind, payload.message, details.error || '');
|
||||
|
||||
if (!transport || details.skipTransport === true || transportDepth > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
transportDepth += 1;
|
||||
await transport(payload);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('client error transport failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
transportDepth = Math.max(0, transportDepth - 1);
|
||||
}
|
||||
}
|
||||
150
shine-UI/js/services/crypto-utils.js
Normal file
150
shine-UI/js/services/crypto-utils.js
Normal file
@ -0,0 +1,150 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
|
||||
function base64UrlToBase64(value) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
||||
return normalized + '='.repeat(padLen);
|
||||
}
|
||||
|
||||
export function randomBase64(byteLen = 32) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64ToBytes(base64) {
|
||||
const normalized = (base64 || '').trim();
|
||||
const binary = atob(normalized);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function utf8Bytes(value) {
|
||||
return encoder.encode(value);
|
||||
}
|
||||
|
||||
export async function sha256Bytes(bytes) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export async function sha256Text(text) {
|
||||
return sha256Bytes(utf8Bytes(text));
|
||||
}
|
||||
|
||||
export async function derivePasswordSeed(password, suffix) {
|
||||
const base = await sha256Text(password || '');
|
||||
const concat = `${bytesToBase64(base)}${suffix}`;
|
||||
return sha256Text(concat);
|
||||
}
|
||||
|
||||
function ed25519Pkcs8FromSeed(seed32) {
|
||||
if (seed32.length !== 32) {
|
||||
throw new Error('Для Ed25519 нужен seed длиной 32 байта');
|
||||
}
|
||||
const prefix = new Uint8Array([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
const out = new Uint8Array(prefix.length + seed32.length);
|
||||
out.set(prefix, 0);
|
||||
out.set(seed32, prefix.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function deriveEd25519FromPassword(password, suffix) {
|
||||
const seed = await derivePasswordSeed(password, suffix);
|
||||
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
||||
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))),
|
||||
privatePkcs8B64: bytesToBase64(pkcs8),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
utf8Bytes(storagePwd),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
iterations: 210000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
baseKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256,
|
||||
},
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptJsonWithStoragePwd(value, storagePwd) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plainBytes = utf8Bytes(JSON.stringify(value));
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
|
||||
|
||||
return {
|
||||
saltB64: bytesToBase64(salt),
|
||||
ivB64: bytesToBase64(iv),
|
||||
cipherB64: bytesToBase64(new Uint8Array(cipher)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
||||
const salt = base64ToBytes(envelope.saltB64);
|
||||
const iv = base64ToBytes(envelope.ivB64);
|
||||
const cipher = base64ToBytes(envelope.cipherB64);
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function generateEd25519Pair() {
|
||||
return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
export async function exportEd25519PublicKeyB64(publicKey) {
|
||||
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function exportPkcs8B64(privateKey) {
|
||||
const raw = await crypto.subtle.exportKey('pkcs8', privateKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function importPkcs8Ed25519(pkcs8B64) {
|
||||
return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||
}
|
||||
|
||||
export async function signBase64(privateKey, text) {
|
||||
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||
return bytesToBase64(new Uint8Array(signature));
|
||||
}
|
||||
87
shine-UI/js/services/key-vault.js
Normal file
87
shine-UI/js/services/key-vault.js
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
decryptJsonWithStoragePwd,
|
||||
encryptJsonWithStoragePwd,
|
||||
} from './crypto-utils.js?v=20260330210201';
|
||||
|
||||
const DB_NAME = 'shine-ui-auth';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_SECRETS = 'encrypted-secrets';
|
||||
const STORE_SESSIONS = 'session-keys';
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_SECRETS)) {
|
||||
db.createObjectStore(STORE_SECRETS, { keyPath: 'login' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_SESSIONS)) {
|
||||
db.createObjectStore(STORE_SESSIONS, { keyPath: 'login' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB недоступен'));
|
||||
});
|
||||
}
|
||||
|
||||
async function put(storeName, value) {
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite');
|
||||
tx.objectStore(storeName).put(value);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error || new Error('Ошибка записи в IndexedDB'));
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function get(storeName, key) {
|
||||
const db = await openDb();
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const req = tx.objectStore(storeName).get(key);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB'));
|
||||
});
|
||||
db.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
|
||||
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
|
||||
await put(STORE_SECRETS, {
|
||||
login,
|
||||
encrypted,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEncryptedUserSecrets(login, storagePwd) {
|
||||
const row = await get(STORE_SECRETS, login);
|
||||
if (!row?.encrypted) {
|
||||
throw new Error('На устройстве нет сохранённых ключей для этого логина');
|
||||
}
|
||||
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
|
||||
}
|
||||
|
||||
export async function saveSessionMaterial(login, material) {
|
||||
await put(STORE_SESSIONS, {
|
||||
login,
|
||||
...material,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSessionMaterial(login) {
|
||||
return get(STORE_SESSIONS, login);
|
||||
}
|
||||
|
||||
export async function clearClientAuthData() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('Не удалось очистить IndexedDB'));
|
||||
request.onblocked = () => reject(new Error('Очистка IndexedDB заблокирована открытыми соединениями'));
|
||||
});
|
||||
}
|
||||
145
shine-UI/js/services/ws-client.js
Normal file
145
shine-UI/js/services/ws-client.js
Normal file
@ -0,0 +1,145 @@
|
||||
import { captureClientError } from './client-error-reporter.js?v=20260331000100';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
function buildWsUrl(raw) {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
|
||||
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function createRequestId(op) {
|
||||
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export class WsJsonClient {
|
||||
constructor(url) {
|
||||
this.url = buildWsUrl(url);
|
||||
this.ws = null;
|
||||
this.pending = new Map();
|
||||
this.openPromise = null;
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
if (this.openPromise) return this.openPromise;
|
||||
|
||||
this.openPromise = new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
resolve();
|
||||
}, { once: true });
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
captureClientError({
|
||||
kind: 'ws_open_error',
|
||||
message: `Failed to connect WebSocket ${this.url}`,
|
||||
context: { url: this.url },
|
||||
});
|
||||
reject(new Error(`Не удалось подключиться к ${this.url}`));
|
||||
}, { once: true });
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this.failPending('Соединение WebSocket закрыто');
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
}).finally(() => {
|
||||
this.openPromise = null;
|
||||
});
|
||||
|
||||
return this.openPromise;
|
||||
}
|
||||
|
||||
async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||
await this.open();
|
||||
const requestId = createRequestId(op);
|
||||
const body = { op, requestId, payload };
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
if (op !== 'ClientErrorLog') {
|
||||
captureClientError({
|
||||
kind: 'ws_timeout',
|
||||
message: `Timeout waiting for ${op}`,
|
||||
requestOp: op,
|
||||
requestIdRef: requestId,
|
||||
context: { url: this.url, timeoutMs },
|
||||
});
|
||||
}
|
||||
reject(new Error(`Таймаут ответа для операции ${op}`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(requestId, {
|
||||
op,
|
||||
resolve: (value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.send(JSON.stringify(body));
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(raw) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
captureClientError({
|
||||
kind: 'ws_bad_json',
|
||||
message: 'Received non-JSON message from server',
|
||||
context: { raw: String(raw).slice(0, 1000) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = data?.requestId;
|
||||
if (!requestId) return;
|
||||
|
||||
const slot = this.pending.get(requestId);
|
||||
if (!slot) return;
|
||||
this.pending.delete(requestId);
|
||||
slot.resolve(data);
|
||||
}
|
||||
|
||||
failPending(message) {
|
||||
const pendingOps = [...this.pending.values()]
|
||||
.map((slot) => slot.op)
|
||||
.filter((op) => op && op !== 'ClientErrorLog');
|
||||
if (pendingOps.length > 0) {
|
||||
captureClientError({
|
||||
kind: 'ws_closed',
|
||||
message,
|
||||
context: { url: this.url, pendingOps },
|
||||
});
|
||||
}
|
||||
|
||||
const error = new Error(message);
|
||||
for (const [, slot] of this.pending.entries()) {
|
||||
slot.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
261
shine-UI/js/state.js
Normal file
261
shine-UI/js/state.js
Normal file
@ -0,0 +1,261 @@
|
||||
import { chatMessages, wallet } from './mock-data.js?v=20260330210201';
|
||||
import { AuthService } from './services/auth-service.js?v=20260330210201';
|
||||
import { clearClientAuthData } from './services/key-vault.js?v=20260330210201';
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||
const INVALID_SESSION_CODES = new Set([
|
||||
'NOT_AUTHENTICATED',
|
||||
'SESSION_NOT_FOUND',
|
||||
'SESSION_KEY_NOT_ACTUAL',
|
||||
'SESSION_OF_ANOTHER_USER',
|
||||
]);
|
||||
|
||||
function loadStoredSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(session) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// ignore quota/storage errors for prototype
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialState({ withStoredSession = true } = {}) {
|
||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||
return {
|
||||
chats: clone(chatMessages),
|
||||
notificationsTab: 'replies',
|
||||
pageLabelCollapsed: false,
|
||||
session: {
|
||||
isAuthorized: false,
|
||||
login: storedSession?.login || '',
|
||||
sessionId: storedSession?.sessionId || '',
|
||||
storagePwdInMemory: '',
|
||||
},
|
||||
startHint: '',
|
||||
entrySettings: {
|
||||
language: 'ru',
|
||||
solanaServer: 'https://api.mainnet-beta.solana.com',
|
||||
shineServer: 'wss://shineup.me/ws',
|
||||
arweaveServer: 'https://arweave.net',
|
||||
statuses: {
|
||||
solanaServer: 'idle',
|
||||
shineServer: 'idle',
|
||||
arweaveServer: 'idle',
|
||||
},
|
||||
},
|
||||
registrationDraft: {
|
||||
flowType: '',
|
||||
login: '',
|
||||
password: '',
|
||||
sessionId: '',
|
||||
storagePwd: '',
|
||||
pendingKeyBundle: null,
|
||||
pendingSessionMaterial: null,
|
||||
},
|
||||
loginDraft: {
|
||||
login: storedSession?.login || '',
|
||||
password: '',
|
||||
},
|
||||
registrationPayment: {
|
||||
walletAddress: wallet.publicAddress,
|
||||
balanceSOL: '0.0068',
|
||||
},
|
||||
keyStorage: {
|
||||
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||
deviceKey: 'Ключ device хранится в зашифрованном виде',
|
||||
saveRoot: false,
|
||||
saveBlockchain: true,
|
||||
saveDevice: true,
|
||||
},
|
||||
deviceConnect: {
|
||||
root: true,
|
||||
blockchain: true,
|
||||
device: true,
|
||||
},
|
||||
authUi: {
|
||||
busy: false,
|
||||
error: '',
|
||||
info: '',
|
||||
},
|
||||
sessions: [],
|
||||
channelsFeed: null,
|
||||
channelsIndex: {},
|
||||
localChannelPosts: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const state = createInitialState();
|
||||
|
||||
export const authService = new AuthService(state.entrySettings.shineServer);
|
||||
let onSessionReset = null;
|
||||
|
||||
export function getChatMessages(chatId) {
|
||||
if (!state.chats[chatId]) {
|
||||
state.chats[chatId] = [];
|
||||
}
|
||||
return state.chats[chatId];
|
||||
}
|
||||
|
||||
export function addChatMessage(chatId, text) {
|
||||
const message = text.trim();
|
||||
if (!message) return;
|
||||
getChatMessages(chatId).push({ from: 'out', text: message });
|
||||
}
|
||||
|
||||
export function togglePageLabel() {
|
||||
state.pageLabelCollapsed = !state.pageLabelCollapsed;
|
||||
}
|
||||
|
||||
export function ensureChat(chatId) {
|
||||
return getChatMessages(chatId);
|
||||
}
|
||||
|
||||
export function checkServerAvailability(address) {
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (!normalized) return 'unavailable';
|
||||
|
||||
const looksLikeUrl = /^(https?:\/\/|wss?:\/\/)[a-z0-9.-]+/i.test(normalized);
|
||||
const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized);
|
||||
return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
|
||||
}
|
||||
|
||||
export async function saveEntrySettings(nextSettings) {
|
||||
state.entrySettings = {
|
||||
...state.entrySettings,
|
||||
...nextSettings,
|
||||
statuses: {
|
||||
...state.entrySettings.statuses,
|
||||
...(nextSettings.statuses || {}),
|
||||
},
|
||||
};
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
|
||||
}
|
||||
|
||||
export function clearStartHint() {
|
||||
state.startHint = '';
|
||||
}
|
||||
|
||||
export function setAuthBusy(flag) {
|
||||
state.authUi.busy = flag;
|
||||
}
|
||||
|
||||
export function setAuthError(message) {
|
||||
state.authUi.error = message || '';
|
||||
}
|
||||
|
||||
export function setAuthInfo(message) {
|
||||
state.authUi.info = message || '';
|
||||
}
|
||||
|
||||
export function clearAuthMessages() {
|
||||
state.authUi.error = '';
|
||||
state.authUi.info = '';
|
||||
}
|
||||
|
||||
export function authorizeSession({ login, sessionId, storagePwd }) {
|
||||
state.session.isAuthorized = true;
|
||||
state.session.login = login;
|
||||
state.session.sessionId = sessionId;
|
||||
state.session.storagePwdInMemory = storagePwd;
|
||||
persistSession({
|
||||
isAuthorized: true,
|
||||
login,
|
||||
sessionId,
|
||||
});
|
||||
state.startHint = '';
|
||||
}
|
||||
|
||||
export function setSessionResetHandler(handler) {
|
||||
onSessionReset = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
export function isSessionInvalidError(error) {
|
||||
return INVALID_SESSION_CODES.has(error?.code);
|
||||
}
|
||||
|
||||
export async function refreshSessions() {
|
||||
state.sessions = await authService.listSessions();
|
||||
return state.sessions;
|
||||
}
|
||||
|
||||
function resetStateForSignedOut() {
|
||||
const next = createInitialState({ withStoredSession: false });
|
||||
state.chats = next.chats;
|
||||
state.notificationsTab = next.notificationsTab;
|
||||
state.session = next.session;
|
||||
state.startHint = next.startHint;
|
||||
state.registrationDraft = next.registrationDraft;
|
||||
state.loginDraft = next.loginDraft;
|
||||
state.registrationPayment = next.registrationPayment;
|
||||
state.keyStorage = next.keyStorage;
|
||||
state.deviceConnect = next.deviceConnect;
|
||||
state.authUi = next.authUi;
|
||||
state.sessions = next.sessions;
|
||||
}
|
||||
|
||||
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
||||
clearStoredSession();
|
||||
resetStateForSignedOut();
|
||||
authService.close();
|
||||
try {
|
||||
await clearClientAuthData();
|
||||
} catch {
|
||||
// ignore cleanup errors in prototype mode
|
||||
}
|
||||
if (infoMessage) {
|
||||
state.startHint = infoMessage;
|
||||
}
|
||||
if (onSessionReset) {
|
||||
onSessionReset();
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshRegistrationBalance() {
|
||||
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
||||
state.registrationPayment.balanceSOL = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setChannelsFeed(feed, index) {
|
||||
state.channelsFeed = feed || null;
|
||||
state.channelsIndex = index || {};
|
||||
}
|
||||
|
||||
export function getLocalChannelPosts(channelId) {
|
||||
if (!channelId) return [];
|
||||
if (!state.localChannelPosts[channelId]) {
|
||||
state.localChannelPosts[channelId] = [];
|
||||
}
|
||||
return state.localChannelPosts[channelId];
|
||||
}
|
||||
|
||||
export function addLocalChannelPost(channelId, post) {
|
||||
if (!channelId) return;
|
||||
const text = post?.body?.trim();
|
||||
if (!text) return;
|
||||
|
||||
getLocalChannelPosts(channelId).push({
|
||||
title: post.title || `${state.session.login || 'Вы'} • сейчас`,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
765
shine-UI/styles/components.css
Normal file
765
shine-UI/styles/components.css
Normal file
@ -0,0 +1,765 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 74px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
.text-btn,
|
||||
.primary-btn,
|
||||
.ghost-btn {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card-soft);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover,
|
||||
.text-btn:hover,
|
||||
.primary-btn:hover,
|
||||
.ghost-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(120deg, var(--accent-soft), rgba(82, 120, 240, 0.22));
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(132, 244, 161, 0.35);
|
||||
color: #d7ffe3;
|
||||
background: rgba(132, 244, 161, 0.09);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.profile-badge-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge.alt {
|
||||
border-color: rgba(83, 216, 251, 0.35);
|
||||
color: #dff8ff;
|
||||
background: rgba(83, 216, 251, 0.11);
|
||||
}
|
||||
|
||||
.profile-help-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-help-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.profile-help-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(5, 9, 16, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.profile-help-dialog {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.profile-help-text {
|
||||
color: #d8e3ff;
|
||||
line-height: 1.45;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
min-height: calc(100dvh - 48px - env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 126px;
|
||||
height: 126px;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
font-size: 32px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.auth-actions,
|
||||
.auth-footer-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.auth-footer-actions {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auth-copy {
|
||||
line-height: 1.45;
|
||||
color: #d8e3ff;
|
||||
}
|
||||
|
||||
.auth-status-card {
|
||||
width: min(100%, 320px);
|
||||
color: #d8e3ff;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #b2c2e6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(83, 216, 251, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
||||
}
|
||||
|
||||
.wrap-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-line.is-available {
|
||||
color: #8ef0a8;
|
||||
}
|
||||
|
||||
.status-line.is-unavailable {
|
||||
color: #ff8d97;
|
||||
}
|
||||
|
||||
.server-check-btn.is-available {
|
||||
border-color: rgba(132, 244, 161, 0.42);
|
||||
background: rgba(132, 244, 161, 0.12);
|
||||
color: #d7ffe3;
|
||||
}
|
||||
|
||||
.server-check-btn.is-unavailable {
|
||||
border-color: rgba(255, 113, 143, 0.42);
|
||||
background: rgba(255, 113, 143, 0.12);
|
||||
color: #ffd7df;
|
||||
}
|
||||
|
||||
.help-fab,
|
||||
.square-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--card-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-fab {
|
||||
position: fixed;
|
||||
right: max(20px, calc((100vw - min(100vw, 430px)) / 2 + 20px));
|
||||
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.inline-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: block;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(83, 216, 251, 0.08);
|
||||
border: 1px solid rgba(83, 216, 251, 0.22);
|
||||
color: #d9f8ff;
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: min(220px, 100%);
|
||||
aspect-ratio: 1;
|
||||
fill: #eff5ff;
|
||||
background: #111723;
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.camera-shell {
|
||||
position: relative;
|
||||
min-height: 380px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: #09101a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 380px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-frame {
|
||||
position: absolute;
|
||||
inset: 70px 40px 110px;
|
||||
border: 3px solid rgba(83, 216, 251, 0.85);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 0 0 999px rgba(5, 9, 16, 0.38);
|
||||
}
|
||||
|
||||
.camera-hint,
|
||||
.camera-error {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 14, 23, 0.78);
|
||||
}
|
||||
|
||||
.camera-hint {
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
top: 18px;
|
||||
color: #ffd7df;
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
width: 100%;
|
||||
min-height: 380px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #c8d6f9;
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(83, 216, 251, 0.16), transparent 48%),
|
||||
linear-gradient(180deg, #0a1220, #070d17);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(130deg, #3c4f73, #243352);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #e5ebff;
|
||||
}
|
||||
|
||||
.avatar.large {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.meta-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.unread {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
color: #08212a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(20, 28, 44, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: rgba(83, 216, 251, 0.14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
color: #c5d2f4;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.page-label.is-collapsed {
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.page-label-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-label-hint {
|
||||
margin-bottom: 3px;
|
||||
color: #8ea2cd;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-label-caption {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-label-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-label-toggle:hover {
|
||||
border-color: rgba(83, 216, 251, 0.5);
|
||||
background: rgba(83, 216, 251, 0.16);
|
||||
}
|
||||
|
||||
.chat-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 10px;
|
||||
min-height: calc(100dvh - 210px);
|
||||
}
|
||||
|
||||
.messages-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 76%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bubble.in {
|
||||
justify-self: start;
|
||||
background: #1f2c46;
|
||||
border-top-left-radius: 6px;
|
||||
}
|
||||
|
||||
.bubble.out {
|
||||
justify-self: end;
|
||||
background: #273f63;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contact-search-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(83, 216, 251, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: rgba(83, 216, 251, 0.4);
|
||||
}
|
||||
|
||||
.session-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-tab {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
min-height: 36px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-tab.is-active {
|
||||
color: var(--text);
|
||||
border-color: rgba(83, 216, 251, 0.45);
|
||||
background: rgba(83, 216, 251, 0.15);
|
||||
}
|
||||
|
||||
.session-current-badge {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #d7ffe3;
|
||||
border: 1px solid rgba(132, 244, 161, 0.36);
|
||||
background: rgba(132, 244, 161, 0.1);
|
||||
}
|
||||
|
||||
.key-value {
|
||||
font-family: "IBM Plex Mono", "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
color: #dce7ff;
|
||||
}
|
||||
|
||||
.qr-demo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
#f6fbff 0 8px,
|
||||
#0f1524 8px 16px,
|
||||
#f6fbff 16px 24px,
|
||||
#0f1524 24px 32px,
|
||||
#f6fbff 32px 40px,
|
||||
#0f1524 40px 48px,
|
||||
#f6fbff 48px 56px,
|
||||
#0f1524 56px 64px
|
||||
);
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.modal-shell[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 24;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(5, 9, 16, 0.74);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.network-board {
|
||||
position: relative;
|
||||
height: 290px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
background: radial-gradient(circle at center, rgba(83, 216, 251, 0.08), rgba(255, 255, 255, 0.01));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.network-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 74px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 4px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
background: #2f4265;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.node.center .node-dot {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(130deg, #3a5f8e, #3dc4df);
|
||||
color: #061119;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 11px;
|
||||
color: #d6e2ff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: rgba(83, 216, 251, 0.16);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(100%, 390px);
|
||||
background: #172238;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #dbe7ff;
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.channels-scroll-wrap {
|
||||
position: relative;
|
||||
max-height: 58vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.channels-groups {
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
.channels-divider {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
|
||||
.channels-scroll-hint {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
width: 4px;
|
||||
height: calc(100% - 8px);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15));
|
||||
pointer-events: none;
|
||||
}
|
||||
55
shine-UI/styles/layout.css
Normal file
55
shine-UI/styles/layout.css
Normal file
@ -0,0 +1,55 @@
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(100vw, 430px);
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99));
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 118px;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.screen-content.no-app-chrome {
|
||||
bottom: 0;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.page-label-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 99px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(10, 14, 23, 0) 0%, rgba(10, 14, 23, 0.95) 42%);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.app-shell {
|
||||
margin: 16px 0;
|
||||
height: calc(100dvh - 32px);
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
51
shine-UI/styles/main.css
Normal file
51
shine-UI/styles/main.css
Normal file
@ -0,0 +1,51 @@
|
||||
:root {
|
||||
--bg-0: #080b12;
|
||||
--bg-1: #101624;
|
||||
--bg-2: #171f32;
|
||||
--card: #1a2436;
|
||||
--card-soft: #202d45;
|
||||
--line: #2a3854;
|
||||
--text: #ebf1ff;
|
||||
--text-muted: #99a8cb;
|
||||
--accent: #53d8fb;
|
||||
--accent-soft: rgba(83, 216, 251, 0.17);
|
||||
--danger: #ff718f;
|
||||
--ok: #84f4a1;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
--font-main: "Manrope", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: radial-gradient(circle at 22% -10%, #1f355e 0%, var(--bg-0) 45%) fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font-main);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@ -45,10 +45,18 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
|
||||
// --- NEW: Ping ---
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
||||
|
||||
@ -85,10 +93,14 @@ public final class JsonHandlerRegistry {
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", new Net_Ping_Handler()),
|
||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler())
|
||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
||||
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler())
|
||||
|
||||
// --- subscriptions ---
|
||||
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
|
||||
@ -119,10 +131,14 @@ public final class JsonHandlerRegistry {
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", Net_Ping_Request.class),
|
||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class)
|
||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class)
|
||||
);
|
||||
|
||||
private JsonHandlerRegistry() { }
|
||||
|
||||
@ -5,6 +5,7 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
@ -25,6 +26,9 @@ import shine.db.entities.BlockEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
@ -128,6 +132,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||
case "channel_name_already_exists" -> "Канал с таким именем уже существует";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
@ -228,6 +233,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||
try {
|
||||
if (channelNameExists(blockchainName, createChannelBody.channelName)) {
|
||||
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: channel_name_check_failed (blockchainName={}, channelName={})",
|
||||
blockchainName, createChannelBody.channelName, e);
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
}
|
||||
|
||||
// 4.2) запрет дырок: blockNumber строго last+1
|
||||
int expectedBlockNumber = serverLastNum + 1;
|
||||
if (block.blockNumber != expectedBlockNumber) {
|
||||
@ -378,6 +395,32 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return Base64Ws.decode(b64);
|
||||
}
|
||||
|
||||
private boolean channelNameExists(String blockchainName, String channelName) throws Exception {
|
||||
String sql = """
|
||||
SELECT block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND msg_type = 0 AND msg_sub_type = 1
|
||||
""";
|
||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
try {
|
||||
BchBlockEntry entry = new BchBlockEntry(bytes);
|
||||
if (entry.body instanceof CreateChannelBody ccb) {
|
||||
if (ccb.channelName.equalsIgnoreCase(channelName)) return true;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore bad historic rows, uniqueness check is best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static long safeAdd(long a, long b) {
|
||||
long r = a + b;
|
||||
if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow");
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.body.BodyRecord;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.TextBody;
|
||||
import shine.db.MsgSubType;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class ChannelsReadSupport {
|
||||
static final int MSG_TYPE_TEXT = 1;
|
||||
static final int MSG_TYPE_TECH = 0;
|
||||
|
||||
private ChannelsReadSupport() {}
|
||||
|
||||
static String canonicalLogin(Connection c, String anyCaseLogin) throws SQLException {
|
||||
String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, anyCaseLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
if (rootNumber == 0) return "0";
|
||||
|
||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) return ccb.channelName;
|
||||
return null;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("cnt") : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
PostBlock pb = new PostBlock();
|
||||
pb.login = rs.getString("login");
|
||||
pb.bchName = rs.getString("bch_name");
|
||||
pb.blockNumber = rs.getInt("block_number");
|
||||
pb.blockHash = rs.getBytes("block_hash");
|
||||
pb.blockBytes = rs.getBytes("block_bytes");
|
||||
return pb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||
ps.setString(4, ownerBch);
|
||||
ps.setInt(5, originalBlockNumber);
|
||||
ps.setBytes(6, originalHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
PostBlock pb = new PostBlock();
|
||||
pb.login = rs.getString("login");
|
||||
pb.bchName = rs.getString("bch_name");
|
||||
pb.blockNumber = rs.getInt("block_number");
|
||||
pb.blockHash = rs.getBytes("block_hash");
|
||||
pb.blockBytes = rs.getBytes("block_bytes");
|
||||
return pb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static TextInfo parseTextAndTime(byte[] blockBytes) {
|
||||
try {
|
||||
BchBlockEntry e = new BchBlockEntry(blockBytes);
|
||||
TextInfo ti = new TextInfo();
|
||||
ti.createdAtMs = e.timestamp * 1000L;
|
||||
if (e.body instanceof TextBody tb) {
|
||||
ti.text = tb.message;
|
||||
}
|
||||
return ti;
|
||||
} catch (Exception ex) {
|
||||
return new TextInfo();
|
||||
}
|
||||
}
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
ps.setInt(5, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PostBlock pb = new PostBlock();
|
||||
pb.login = rs.getString("login");
|
||||
pb.bchName = rs.getString("bch_name");
|
||||
pb.blockNumber = rs.getInt("block_number");
|
||||
pb.blockHash = rs.getBytes("block_hash");
|
||||
pb.blockBytes = rs.getBytes("block_bytes");
|
||||
out.add(pb);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<PostBlock> versionsForPost(Connection c, String ownerBch, int originalBlock, byte[] originalHash) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||
ps.setString(4, ownerBch);
|
||||
ps.setInt(5, originalBlock);
|
||||
ps.setBytes(6, originalHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PostBlock pb = new PostBlock();
|
||||
pb.login = rs.getString("login");
|
||||
pb.bchName = rs.getString("bch_name");
|
||||
pb.blockNumber = rs.getInt("block_number");
|
||||
pb.blockHash = rs.getBytes("block_hash");
|
||||
pb.blockBytes = rs.getBytes("block_bytes");
|
||||
out.add(pb);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
ps.setInt(2, blockNumber);
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new int[] {0, 0};
|
||||
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] hexToBytes(String s) {
|
||||
if (s == null) return null;
|
||||
String x = s.trim();
|
||||
if ((x.length() & 1) != 0) throw new IllegalArgumentException("hex length must be even");
|
||||
byte[] out = new byte[x.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int hi = Character.digit(x.charAt(i * 2), 16);
|
||||
int lo = Character.digit(x.charAt(i * 2 + 1), 16);
|
||||
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
|
||||
out[i] = (byte) ((hi << 4) | lo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static final class PostBlock {
|
||||
String login;
|
||||
String bchName;
|
||||
int blockNumber;
|
||||
byte[] blockHash;
|
||||
byte[] blockBytes;
|
||||
}
|
||||
|
||||
static final class TextInfo {
|
||||
String text = "";
|
||||
long createdAtMs = 0L;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.SqliteDbController;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetChannelMessages_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetChannelMessages_Request req = (Net_GetChannelMessages_Request) baseRequest;
|
||||
if (req.getChannel() == null
|
||||
|| req.getChannel().getOwnerBlockchainName() == null
|
||||
|| req.getChannel().getOwnerBlockchainName().isBlank()
|
||||
|| req.getChannel().getChannelRootBlockNumber() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
}
|
||||
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
String ownerBch = req.getChannel().getOwnerBlockchainName();
|
||||
int lineCode = req.getChannel().getChannelRootBlockNumber();
|
||||
|
||||
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
|
||||
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
|
||||
channel.setOwnerBlockchainName(ownerBch);
|
||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
rootRef.setBlockNumber(lineCode);
|
||||
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
||||
channel.setChannelRoot(rootRef);
|
||||
resp.setChannel(channel);
|
||||
|
||||
List<ChannelsReadSupport.PostBlock> posts = ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc);
|
||||
List<Net_GetChannelMessages_Response.MessageItem> items = new ArrayList<>();
|
||||
|
||||
for (ChannelsReadSupport.PostBlock post : posts) {
|
||||
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
|
||||
Net_GetChannelMessages_Response.BlockRef msgRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
msgRef.setBlockNumber(post.blockNumber);
|
||||
msgRef.setBlockHash(ChannelsReadSupport.toHex(post.blockHash));
|
||||
item.setMessageRef(msgRef);
|
||||
item.setAuthorLogin(post.login);
|
||||
item.setAuthorBlockchainName(post.bchName);
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versionsOut = new ArrayList<>();
|
||||
int index = 1;
|
||||
|
||||
ChannelsReadSupport.TextInfo postText = ChannelsReadSupport.parseTextAndTime(post.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem v1 = new Net_GetChannelMessages_Response.VersionItem();
|
||||
v1.setVersionIndex(index++);
|
||||
v1.setBlockNumber(post.blockNumber);
|
||||
v1.setBlockHash(ChannelsReadSupport.toHex(post.blockHash));
|
||||
v1.setText(postText.text);
|
||||
v1.setCreatedAtMs(postText.createdAtMs);
|
||||
versionsOut.add(v1);
|
||||
|
||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
||||
ve.setVersionIndex(index++);
|
||||
ve.setBlockNumber(edit.blockNumber);
|
||||
ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
ve.setText(editText.text);
|
||||
ve.setCreatedAtMs(editText.createdAtMs);
|
||||
versionsOut.add(ve);
|
||||
}
|
||||
|
||||
item.setVersions(versionsOut);
|
||||
item.setVersionsTotal(versionsOut.size());
|
||||
|
||||
Net_GetChannelMessages_Response.VersionItem lastV = versionsOut.get(versionsOut.size() - 1);
|
||||
item.setText(lastV.getText());
|
||||
item.setCreatedAtMs(postText.createdAtMs);
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
resp.setMessages(items);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetChannelMessages failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageThread_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
|
||||
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp());
|
||||
int depthDown = req.getDepthDown() == null ? 2 : Math.max(0, req.getDepthDown());
|
||||
int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
|
||||
if (focusRow == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
|
||||
}
|
||||
|
||||
Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
|
||||
resp.setFocus(toNode(c, focusRow));
|
||||
|
||||
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
||||
PostRow cur = focusRow;
|
||||
for (int i = 0; i < depthUp; i++) {
|
||||
if (cur.toBlockNumber == null || cur.toBchName == null) break;
|
||||
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
||||
if (parent == null) break;
|
||||
ancestors.add(0, toNode(c, parent));
|
||||
cur = parent;
|
||||
}
|
||||
resp.setAncestors(ancestors);
|
||||
|
||||
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageThread failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit) throws Exception {
|
||||
if (depthDown <= 0) return List.of();
|
||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||
for (PostRow row : replies) {
|
||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||
t.setNode(toNode(c, row));
|
||||
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit));
|
||||
out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
||||
FROM blocks
|
||||
WHERE msg_type=1 AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
ORDER BY block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
||||
ps.setString(2, toBchName);
|
||||
ps.setInt(3, toBlockNumber);
|
||||
ps.setBytes(4, toBlockHash);
|
||||
ps.setInt(5, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostRow> out = new ArrayList<>();
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PostRow findByNumber(Connection c, String bchName, int blockNumber) throws Exception {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND block_number=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PostRow mapRow(ResultSet rs) throws Exception {
|
||||
PostRow row = new PostRow();
|
||||
row.login = rs.getString("login");
|
||||
row.bchName = rs.getString("bch_name");
|
||||
row.blockNumber = rs.getInt("block_number");
|
||||
row.blockHash = rs.getBytes("block_hash");
|
||||
row.blockBytes = rs.getBytes("block_bytes");
|
||||
row.toBchName = rs.getString("to_bch_name");
|
||||
row.toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||
row.toBlockHash = rs.getBytes("to_block_hash");
|
||||
row.lineCode = (Integer) rs.getObject("line_code");
|
||||
row.msgSubType = rs.getInt("msg_sub_type");
|
||||
return row;
|
||||
}
|
||||
|
||||
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row) throws Exception {
|
||||
Net_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
|
||||
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
||||
ref.setBlockNumber(row.blockNumber);
|
||||
ref.setBlockHash(ChannelsReadSupport.toHex(row.blockHash));
|
||||
node.setMessageRef(ref);
|
||||
node.setAuthorLogin(row.login);
|
||||
node.setAuthorBlockchainName(row.bchName);
|
||||
|
||||
ChannelsReadSupport.TextInfo base = ChannelsReadSupport.parseTextAndTime(row.blockBytes);
|
||||
node.setCreatedAtMs(base.createdAtMs);
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
|
||||
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
|
||||
first.setVersionIndex(1);
|
||||
first.setBlockNumber(row.blockNumber);
|
||||
first.setBlockHash(ChannelsReadSupport.toHex(row.blockHash));
|
||||
first.setText(base.text);
|
||||
first.setCreatedAtMs(base.createdAtMs);
|
||||
versions.add(first);
|
||||
|
||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
||||
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
||||
v.setVersionIndex(versions.size() + 1);
|
||||
v.setBlockNumber(edit.blockNumber);
|
||||
v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
v.setText(et.text);
|
||||
v.setCreatedAtMs(et.createdAtMs);
|
||||
versions.add(v);
|
||||
}
|
||||
|
||||
node.setVersions(versions);
|
||||
node.setVersionsTotal(versions.size());
|
||||
node.setText(versions.get(versions.size() - 1).getText());
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
|
||||
if (row.lineCode != null && row.lineCode >= 0) {
|
||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||
ci.setOwnerBlockchainName(row.bchName);
|
||||
Net_GetChannelMessages_Response.BlockRef root = new Net_GetChannelMessages_Response.BlockRef();
|
||||
root.setBlockNumber(row.lineCode);
|
||||
root.setBlockHash(null);
|
||||
ci.setChannelRoot(root);
|
||||
node.setChannelInfo(ci);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private List<PostRow> findEdits(Connection c, String bch, int targetBlock, byte[] targetHash, int subType) throws Exception {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
ps.setInt(2, subType);
|
||||
ps.setString(3, bch);
|
||||
ps.setInt(4, targetBlock);
|
||||
ps.setBytes(5, targetHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostRow> out = new ArrayList<>();
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PostRow {
|
||||
String login;
|
||||
String bchName;
|
||||
int blockNumber;
|
||||
byte[] blockHash;
|
||||
byte[] blockBytes;
|
||||
String toBchName;
|
||||
Integer toBlockNumber;
|
||||
byte[] toBlockHash;
|
||||
Integer lineCode;
|
||||
int msgSubType;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_ListSubscriptionsFeed_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_ListSubscriptionsFeed_Request req = (Net_ListSubscriptionsFeed_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
|
||||
}
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim());
|
||||
if (canonicalLogin == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
|
||||
}
|
||||
|
||||
Net_ListSubscriptionsFeed_Response resp = new Net_ListSubscriptionsFeed_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(canonicalLogin);
|
||||
|
||||
List<ChannelKey> own = loadOwnChannels(c, canonicalLogin);
|
||||
List<ChannelKey> followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true);
|
||||
List<ChannelKey> followedChannels = loadFollowedChannels(c, canonicalLogin, false);
|
||||
|
||||
resp.setOwnedChannels(buildSummaries(c, own));
|
||||
resp.setFollowedUsersChannels(buildSummaries(c, followedUsersChannels));
|
||||
resp.setFollowedChannels(buildSummaries(c, followedChannels));
|
||||
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("ListSubscriptionsFeed failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, List<ChannelKey> keys) throws Exception {
|
||||
List<Net_ListSubscriptionsFeed_Response.ChannelSummary> out = new ArrayList<>();
|
||||
for (ChannelKey key : keys) {
|
||||
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
|
||||
Net_ListSubscriptionsFeed_Response.ChannelRef channelRef = new Net_ListSubscriptionsFeed_Response.ChannelRef();
|
||||
channelRef.setOwnerLogin(key.ownerLogin);
|
||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
|
||||
channelRef.setPersonal(key.rootNumber == 0);
|
||||
|
||||
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
|
||||
rootRef.setBlockNumber(key.rootNumber);
|
||||
rootRef.setBlockHash(ChannelsReadSupport.toHex(key.rootHash));
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
ChannelsReadSupport.PostBlock actual = ChannelsReadSupport.loadLastVersion(c, key.ownerBch, lastPost.blockNumber, lastPost.blockHash);
|
||||
if (actual == null) actual = lastPost;
|
||||
|
||||
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(actual.blockBytes);
|
||||
Net_ListSubscriptionsFeed_Response.LastMessage lm = new Net_ListSubscriptionsFeed_Response.LastMessage();
|
||||
Net_ListSubscriptionsFeed_Response.BlockRef msgRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
|
||||
msgRef.setBlockNumber(actual.blockNumber);
|
||||
msgRef.setBlockHash(ChannelsReadSupport.toHex(actual.blockHash));
|
||||
lm.setMessageRef(msgRef);
|
||||
lm.setText(textInfo.text);
|
||||
lm.setCreatedAtMs(textInfo.createdAtMs);
|
||||
lm.setAuthorLogin(actual.login);
|
||||
lm.setAuthorBlockchainName(actual.bchName);
|
||||
row.setLastMessage(lm);
|
||||
}
|
||||
|
||||
out.add(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<ChannelKey> loadOwnChannels(Connection c, String canonicalLogin) throws Exception {
|
||||
List<ChannelKey> out = new ArrayList<>();
|
||||
String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE login=? ORDER BY blockchain_name";
|
||||
try (PreparedStatement bchPs = c.prepareStatement(bchSql)) {
|
||||
bchPs.setString(1, canonicalLogin);
|
||||
try (ResultSet bchRs = bchPs.executeQuery()) {
|
||||
while (bchRs.next()) {
|
||||
String bch = bchRs.getString("blockchain_name");
|
||||
out.add(new ChannelKey(canonicalLogin, bch, 0, new byte[32]));
|
||||
|
||||
String chSql = "SELECT block_number,block_hash FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? ORDER BY block_number";
|
||||
try (PreparedStatement chPs = c.prepareStatement(chSql)) {
|
||||
chPs.setString(1, bch);
|
||||
chPs.setInt(2, ChannelsReadSupport.MSG_TYPE_TECH);
|
||||
chPs.setInt(3, 1);
|
||||
try (ResultSet chRs = chPs.executeQuery()) {
|
||||
while (chRs.next()) {
|
||||
out.add(new ChannelKey(canonicalLogin, bch, chRs.getInt("block_number"), chRs.getBytes("block_hash")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<ChannelKey> loadFollowedChannels(Connection c, String canonicalLogin, boolean onlyUserRoots) throws Exception {
|
||||
List<ChannelKey> out = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT cs.to_login, cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
|
||||
FROM connections_state cs
|
||||
WHERE cs.login=? AND cs.rel_type=?
|
||||
ORDER BY cs.to_login, cs.to_bch_name, root_number
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, canonicalLogin);
|
||||
ps.setInt(2, MsgSubType.CONNECTION_FOLLOW);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
int rootNumber = rs.getInt("root_number");
|
||||
if (onlyUserRoots && rootNumber != 0) continue;
|
||||
if (!onlyUserRoots && rootNumber == 0) continue;
|
||||
out.add(new ChannelKey(
|
||||
rs.getString("to_login"),
|
||||
rs.getString("to_bch_name"),
|
||||
rootNumber,
|
||||
rs.getBytes("to_block_hash")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final class ChannelKey {
|
||||
final String ownerLogin;
|
||||
final String ownerBch;
|
||||
final int rootNumber;
|
||||
final byte[] rootHash;
|
||||
|
||||
private ChannelKey(String ownerLogin, String ownerBch, int rootNumber, byte[] rootHash) {
|
||||
this.ownerLogin = ownerLogin;
|
||||
this.ownerBch = ownerBch;
|
||||
this.rootNumber = rootNumber;
|
||||
this.rootHash = rootHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetChannelMessages_Request extends Net_Request {
|
||||
private ChannelSelector channel;
|
||||
private Integer limit;
|
||||
private String sort;
|
||||
|
||||
public ChannelSelector getChannel() { return channel; }
|
||||
public void setChannel(ChannelSelector channel) { this.channel = channel; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public String getSort() { return sort; }
|
||||
public void setSort(String sort) { this.sort = sort; }
|
||||
|
||||
public static class ChannelSelector {
|
||||
private String ownerBlockchainName;
|
||||
private Integer channelRootBlockNumber;
|
||||
private String channelRootBlockHash;
|
||||
|
||||
public String getOwnerBlockchainName() { return ownerBlockchainName; }
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
|
||||
|
||||
public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; }
|
||||
public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
|
||||
|
||||
public String getChannelRootBlockHash() { return channelRootBlockHash; }
|
||||
public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private Channel channel;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
|
||||
public Channel getChannel() { return channel; }
|
||||
public void setChannel(Channel channel) { this.channel = channel; }
|
||||
|
||||
public List<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||
|
||||
public static class Channel {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private BlockRef channelRoot;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
|
||||
public String getOwnerBlockchainName() { return ownerBlockchainName; }
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
|
||||
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public BlockRef getChannelRoot() { return channelRoot; }
|
||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||
}
|
||||
|
||||
public static class MessageItem {
|
||||
private BlockRef messageRef;
|
||||
private String authorLogin;
|
||||
private String authorBlockchainName;
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private int repliesCount;
|
||||
private int versionsTotal;
|
||||
private List<VersionItem> versions = new ArrayList<>();
|
||||
|
||||
public BlockRef getMessageRef() { return messageRef; }
|
||||
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||
|
||||
public String getAuthorLogin() { return authorLogin; }
|
||||
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||
|
||||
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public int getLikesCount() { return likesCount; }
|
||||
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
||||
|
||||
public int getRepliesCount() { return repliesCount; }
|
||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||
|
||||
public int getVersionsTotal() { return versionsTotal; }
|
||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||
|
||||
public List<VersionItem> getVersions() { return versions; }
|
||||
public void setVersions(List<VersionItem> versions) { this.versions = versions; }
|
||||
}
|
||||
|
||||
public static class VersionItem {
|
||||
private int versionIndex;
|
||||
private int blockNumber;
|
||||
private String blockHash;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public int getVersionIndex() { return versionIndex; }
|
||||
public void setVersionIndex(int versionIndex) { this.versionIndex = versionIndex; }
|
||||
|
||||
public int getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
|
||||
public static class BlockRef {
|
||||
private int blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public int getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetMessageThread_Request extends Net_Request {
|
||||
private MessageSelector message;
|
||||
private Integer depthUp;
|
||||
private Integer depthDown;
|
||||
private Integer limitChildrenPerNode;
|
||||
|
||||
public MessageSelector getMessage() { return message; }
|
||||
public void setMessage(MessageSelector message) { this.message = message; }
|
||||
|
||||
public Integer getDepthUp() { return depthUp; }
|
||||
public void setDepthUp(Integer depthUp) { this.depthUp = depthUp; }
|
||||
|
||||
public Integer getDepthDown() { return depthDown; }
|
||||
public void setDepthDown(Integer depthDown) { this.depthDown = depthDown; }
|
||||
|
||||
public Integer getLimitChildrenPerNode() { return limitChildrenPerNode; }
|
||||
public void setLimitChildrenPerNode(Integer limitChildrenPerNode) { this.limitChildrenPerNode = limitChildrenPerNode; }
|
||||
|
||||
public static class MessageSelector {
|
||||
private String blockchainName;
|
||||
private Integer blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public Integer getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageThread_Response extends Net_Response {
|
||||
private List<MessageNode> ancestors = new ArrayList<>();
|
||||
private MessageNode focus;
|
||||
private List<MessageNodeTree> descendants = new ArrayList<>();
|
||||
|
||||
public List<MessageNode> getAncestors() { return ancestors; }
|
||||
public void setAncestors(List<MessageNode> ancestors) { this.ancestors = ancestors; }
|
||||
|
||||
public MessageNode getFocus() { return focus; }
|
||||
public void setFocus(MessageNode focus) { this.focus = focus; }
|
||||
|
||||
public List<MessageNodeTree> getDescendants() { return descendants; }
|
||||
public void setDescendants(List<MessageNodeTree> descendants) { this.descendants = descendants; }
|
||||
|
||||
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
|
||||
private ChannelInfo channelInfo;
|
||||
|
||||
public ChannelInfo getChannelInfo() { return channelInfo; }
|
||||
public void setChannelInfo(ChannelInfo channelInfo) { this.channelInfo = channelInfo; }
|
||||
}
|
||||
|
||||
public static class ChannelInfo {
|
||||
private String ownerBlockchainName;
|
||||
private Net_GetChannelMessages_Response.BlockRef channelRoot;
|
||||
|
||||
public String getOwnerBlockchainName() { return ownerBlockchainName; }
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
|
||||
|
||||
public Net_GetChannelMessages_Response.BlockRef getChannelRoot() { return channelRoot; }
|
||||
public void setChannelRoot(Net_GetChannelMessages_Response.BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||
}
|
||||
|
||||
public static class MessageNodeTree {
|
||||
private MessageNode node;
|
||||
private List<MessageNodeTree> children = new ArrayList<>();
|
||||
|
||||
public MessageNode getNode() { return node; }
|
||||
public void setNode(MessageNode node) { this.node = node; }
|
||||
|
||||
public List<MessageNodeTree> getChildren() { return children; }
|
||||
public void setChildren(List<MessageNodeTree> children) { this.children = children; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_ListSubscriptionsFeed_Request extends Net_Request {
|
||||
private String login;
|
||||
private Integer limit;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<ChannelSummary> ownedChannels = new ArrayList<>();
|
||||
private List<ChannelSummary> followedUsersChannels = new ArrayList<>();
|
||||
private List<ChannelSummary> followedChannels = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public List<ChannelSummary> getOwnedChannels() { return ownedChannels; }
|
||||
public void setOwnedChannels(List<ChannelSummary> ownedChannels) { this.ownedChannels = ownedChannels; }
|
||||
|
||||
public List<ChannelSummary> getFollowedUsersChannels() { return followedUsersChannels; }
|
||||
public void setFollowedUsersChannels(List<ChannelSummary> followedUsersChannels) { this.followedUsersChannels = followedUsersChannels; }
|
||||
|
||||
public List<ChannelSummary> getFollowedChannels() { return followedChannels; }
|
||||
public void setFollowedChannels(List<ChannelSummary> followedChannels) { this.followedChannels = followedChannels; }
|
||||
|
||||
public static class ChannelSummary {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
public void setChannel(ChannelRef channel) { this.channel = channel; }
|
||||
|
||||
public int getMessagesCount() { return messagesCount; }
|
||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
public static class ChannelRef {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private boolean personal;
|
||||
private BlockRef channelRoot;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
|
||||
public String getOwnerBlockchainName() { return ownerBlockchainName; }
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
|
||||
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public boolean isPersonal() { return personal; }
|
||||
public void setPersonal(boolean personal) { this.personal = personal; }
|
||||
|
||||
public BlockRef getChannelRoot() { return channelRoot; }
|
||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||
}
|
||||
|
||||
public static class LastMessage {
|
||||
private BlockRef messageRef;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
private String authorLogin;
|
||||
private String authorBlockchainName;
|
||||
|
||||
public BlockRef getMessageRef() { return messageRef; }
|
||||
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
|
||||
public String getAuthorLogin() { return authorLogin; }
|
||||
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||
|
||||
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||
}
|
||||
|
||||
public static class BlockRef {
|
||||
private int blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public int getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
|
||||
import java.net.SocketAddress;
|
||||
|
||||
/**
|
||||
* ClientErrorLog — технический endpoint для фронтенд-ошибок.
|
||||
* Не требует авторизации: клиент должен иметь возможность отправить ошибку
|
||||
* даже если логин/сессия ещё не установлены.
|
||||
*/
|
||||
public class Net_ClientErrorLog_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_ClientErrorLog_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_ClientErrorLog_Request req = (Net_ClientErrorLog_Request) baseRequest;
|
||||
|
||||
if (req.getMessage() == null || req.getMessage().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_FIELDS",
|
||||
"Поле message обязательно для ClientErrorLog"
|
||||
);
|
||||
}
|
||||
|
||||
long serverTs = System.currentTimeMillis();
|
||||
String login = safe(ctx != null ? ctx.getLogin() : null);
|
||||
String sessionId = safe(ctx != null ? ctx.getSessionId() : null);
|
||||
String remote = safe(remoteAddress(ctx));
|
||||
|
||||
log.error(
|
||||
"CLIENT_FRONTEND_ERROR kind={} clientTs={} serverTs={} login={} sessionId={} remote={} route={} href={} sourceUrl={} line={} column={} requestOp={} requestIdRef={} message={} userAgent={} context={}",
|
||||
clip(req.getKind(), 64),
|
||||
req.getClientTs(),
|
||||
serverTs,
|
||||
clip(login, 64),
|
||||
clip(sessionId, 128),
|
||||
clip(remote, 128),
|
||||
clip(req.getRoute(), 200),
|
||||
clip(req.getHref(), 240),
|
||||
clip(req.getSourceUrl(), 240),
|
||||
req.getLineNumber(),
|
||||
req.getColumnNumber(),
|
||||
clip(req.getRequestOp(), 64),
|
||||
clip(req.getRequestIdRef(), 128),
|
||||
clip(req.getMessage(), 500),
|
||||
clip(req.getUserAgent(), 240),
|
||||
clip(req.getContextJson(), 2000)
|
||||
);
|
||||
|
||||
if (req.getStack() != null && !req.getStack().isBlank()) {
|
||||
log.error("CLIENT_FRONTEND_ERROR_STACK requestId={} stack={}",
|
||||
clip(req.getRequestId(), 128),
|
||||
clip(req.getStack(), 8000));
|
||||
}
|
||||
|
||||
Net_ClientErrorLog_Response resp = new Net_ClientErrorLog_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setAccepted(true);
|
||||
resp.setServerTs(serverTs);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static String remoteAddress(ConnectionContext ctx) {
|
||||
if (ctx == null) return "";
|
||||
Session ws = ctx.getWsSession();
|
||||
if (ws == null) return "";
|
||||
SocketAddress remote = ws.getRemoteAddress();
|
||||
return remote != null ? remote.toString() : "";
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String clip(String value, int maxLen) {
|
||||
String cleaned = safe(value)
|
||||
.replace('\n', ' ')
|
||||
.replace('\r', ' ');
|
||||
if (cleaned.length() <= maxLen) {
|
||||
return cleaned;
|
||||
}
|
||||
return cleaned.substring(0, Math.max(0, maxLen - 3)) + "...";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Response;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
/**
|
||||
* GetServerInfo — технический запрос без авторизации.
|
||||
* Возвращает базовую публичную информацию о сервере, чтобы клиент
|
||||
* мог проверить доступность узла и показать его в списке серверов.
|
||||
*/
|
||||
public class Net_GetServerInfo_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final AppConfig CONFIG = AppConfig.getInstance();
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetServerInfo_Request req = (Net_GetServerInfo_Request) baseRequest;
|
||||
|
||||
Net_GetServerInfo_Response resp = new Net_GetServerInfo_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setUrl(CONFIG.getStringOrEmpty("server.info.url"));
|
||||
resp.setVersion(CONFIG.getStringOrEmpty("server.version"));
|
||||
resp.setPhysicalRegion(CONFIG.getStringOrEmpty("server.info.physicalRegion"));
|
||||
resp.setDescription(CONFIG.getStringOrEmpty("server.info.description"));
|
||||
resp.setOrigin(CONFIG.getStringOrEmpty("server.info.origin"));
|
||||
resp.setExtraInfo(CONFIG.getStringOrEmpty("server.info.extraInfo"));
|
||||
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/**
|
||||
* ClientErrorLog:
|
||||
* {
|
||||
* "op": "ClientErrorLog",
|
||||
* "requestId": "req-1",
|
||||
* "payload": {
|
||||
* "kind": "global_error",
|
||||
* "message": "...",
|
||||
* "stack": "...",
|
||||
* "sourceUrl": "...",
|
||||
* "lineNumber": 10,
|
||||
* "columnNumber": 20,
|
||||
* "route": "#/channel-view/own-0",
|
||||
* "href": "https://example/#/channel-view/own-0",
|
||||
* "userAgent": "...",
|
||||
* "clientTs": 1700000000123,
|
||||
* "requestOp": "GetChannelMessages",
|
||||
* "requestIdRef": "GetChannelMessages-123",
|
||||
* "contextJson": "{...}"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public class Net_ClientErrorLog_Request extends Net_Request {
|
||||
|
||||
private String kind;
|
||||
private String message;
|
||||
private String stack;
|
||||
private String sourceUrl;
|
||||
private Integer lineNumber;
|
||||
private Integer columnNumber;
|
||||
private String route;
|
||||
private String href;
|
||||
private String userAgent;
|
||||
private Long clientTs;
|
||||
private String requestOp;
|
||||
private String requestIdRef;
|
||||
private String contextJson;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
|
||||
public String getStack() { return stack; }
|
||||
public void setStack(String stack) { this.stack = stack; }
|
||||
|
||||
public String getSourceUrl() { return sourceUrl; }
|
||||
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
|
||||
|
||||
public Integer getLineNumber() { return lineNumber; }
|
||||
public void setLineNumber(Integer lineNumber) { this.lineNumber = lineNumber; }
|
||||
|
||||
public Integer getColumnNumber() { return columnNumber; }
|
||||
public void setColumnNumber(Integer columnNumber) { this.columnNumber = columnNumber; }
|
||||
|
||||
public String getRoute() { return route; }
|
||||
public void setRoute(String route) { this.route = route; }
|
||||
|
||||
public String getHref() { return href; }
|
||||
public void setHref(String href) { this.href = href; }
|
||||
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
|
||||
public Long getClientTs() { return clientTs; }
|
||||
public void setClientTs(Long clientTs) { this.clientTs = clientTs; }
|
||||
|
||||
public String getRequestOp() { return requestOp; }
|
||||
public void setRequestOp(String requestOp) { this.requestOp = requestOp; }
|
||||
|
||||
public String getRequestIdRef() { return requestIdRef; }
|
||||
public void setRequestIdRef(String requestIdRef) { this.requestIdRef = requestIdRef; }
|
||||
|
||||
public String getContextJson() { return contextJson; }
|
||||
public void setContextJson(String contextJson) { this.contextJson = contextJson; }
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_ClientErrorLog_Response extends Net_Response {
|
||||
|
||||
private long serverTs;
|
||||
private boolean accepted;
|
||||
|
||||
public long getServerTs() { return serverTs; }
|
||||
public void setServerTs(long serverTs) { this.serverTs = serverTs; }
|
||||
|
||||
public boolean isAccepted() { return accepted; }
|
||||
public void setAccepted(boolean accepted) { this.accepted = accepted; }
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/**
|
||||
* GetServerInfo:
|
||||
* {
|
||||
* "op": "GetServerInfo",
|
||||
* "requestId": "req-1",
|
||||
* "payload": {}
|
||||
* }
|
||||
*/
|
||||
public class Net_GetServerInfo_Request extends Net_Request {
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
/**
|
||||
* Ответ GetServerInfo:
|
||||
* {
|
||||
* "op": "GetServerInfo",
|
||||
* "requestId": "req-1",
|
||||
* "status": 200,
|
||||
* "payload": {
|
||||
* "url": "...",
|
||||
* "version": "...",
|
||||
* "physicalRegion": "...",
|
||||
* "description": "...",
|
||||
* "origin": "...",
|
||||
* "extraInfo": "..."
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public class Net_GetServerInfo_Response extends Net_Response {
|
||||
|
||||
private String url;
|
||||
private String version;
|
||||
private String physicalRegion;
|
||||
private String description;
|
||||
private String origin;
|
||||
private String extraInfo;
|
||||
|
||||
public String getUrl() { return url; }
|
||||
public void setUrl(String url) { this.url = url; }
|
||||
|
||||
public String getVersion() { return version; }
|
||||
public void setVersion(String version) { this.version = version; }
|
||||
|
||||
public String getPhysicalRegion() { return physicalRegion; }
|
||||
public void setPhysicalRegion(String physicalRegion) { this.physicalRegion = physicalRegion; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public String getOrigin() { return origin; }
|
||||
public void setOrigin(String origin) { this.origin = origin; }
|
||||
|
||||
public String getExtraInfo() { return extraInfo; }
|
||||
public void setExtraInfo(String extraInfo) { this.extraInfo = extraInfo; }
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package server.logic.ws_protocol.JSON.utils;
|
||||
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
|
||||
/**
|
||||
* Утилиты для строковых публичных ключей, используемых в auth/session API.
|
||||
*
|
||||
* Поддерживаемые форматы:
|
||||
* - legacy: BASE64(32 bytes)
|
||||
* - explicit: ed25519/BASE64(32 bytes)
|
||||
*/
|
||||
public final class AuthKeyUtils {
|
||||
|
||||
private AuthKeyUtils() {}
|
||||
|
||||
public static String normalize(String key, String fieldName) {
|
||||
if (key == null) throw new IllegalArgumentException(fieldName + " is null");
|
||||
String trimmed = key.trim();
|
||||
if (trimmed.isEmpty()) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static byte[] parseEd25519PublicKey(String key, String fieldName) {
|
||||
String normalized = normalize(key, fieldName);
|
||||
|
||||
int slash = normalized.indexOf('/');
|
||||
if (slash < 0) {
|
||||
return Base64Ws.decodeLen(normalized, 32, fieldName);
|
||||
}
|
||||
|
||||
String algorithm = normalized.substring(0, slash).trim();
|
||||
String encodedKey = normalized.substring(slash + 1).trim();
|
||||
|
||||
if (algorithm.isEmpty() || encodedKey.isEmpty()) {
|
||||
throw new IllegalArgumentException(fieldName + " has bad algorithm/key format");
|
||||
}
|
||||
|
||||
if (!"ed25519".equalsIgnoreCase(algorithm)) {
|
||||
throw new UnsupportedOperationException(fieldName + " algorithm is not supported: " + algorithm);
|
||||
}
|
||||
|
||||
return Base64Ws.decodeLen(encodedKey, 32, fieldName);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ import java.util.Objects;
|
||||
public class IT_DeployRestartAndRunRemoteMain {
|
||||
|
||||
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
||||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "10.147.20.7");
|
||||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
||||
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
||||
|
||||
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
|
||||
@ -103,4 +103,4 @@ public class IT_DeployRestartAndRunRemoteMain {
|
||||
try { Thread.sleep(ms); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
94
src/test/java/test/it/cases/IT_00_TechnicalRequests.java
Normal file
94
src/test/java/test/it/cases/IT_00_TechnicalRequests.java
Normal file
@ -0,0 +1,94 @@
|
||||
package test.it.cases;
|
||||
|
||||
import test.it.utils.json.JsonBuilders;
|
||||
import test.it.utils.json.JsonParsers;
|
||||
import test.it.utils.log.TestResult;
|
||||
import test.it.utils.ws.WsSession;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* IT_00_TechnicalRequests
|
||||
* Проверяет технические запросы без авторизации:
|
||||
* - Ping
|
||||
* - GetServerInfo
|
||||
*/
|
||||
public class IT_00_TechnicalRequests {
|
||||
|
||||
public static void main(String[] args) {
|
||||
String summary = run();
|
||||
System.out.println(summary);
|
||||
}
|
||||
|
||||
public static String run() {
|
||||
TestResult r = new TestResult("IT_00_TechnicalRequests");
|
||||
Duration t = Duration.ofSeconds(5);
|
||||
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
checkPing(r, ws, t);
|
||||
checkGetServerInfo(r, ws, t);
|
||||
} catch (Throwable e) {
|
||||
r.fail("IT_00_TechnicalRequests упал: " + e.getMessage());
|
||||
}
|
||||
|
||||
return r.summaryLine();
|
||||
}
|
||||
|
||||
private static void checkPing(TestResult r, WsSession ws, Duration t) {
|
||||
String resp = ws.call("Ping", JsonBuilders.ping(System.currentTimeMillis()), t);
|
||||
|
||||
if (JsonParsers.status(resp) != 200) {
|
||||
r.fail("Ping: ожидали status=200, resp=" + resp);
|
||||
fail("Ping unexpected status");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(JsonParsers.ok(resp))) {
|
||||
r.fail("Ping: ожидали ok=true, resp=" + resp);
|
||||
fail("Ping unexpected ok");
|
||||
}
|
||||
|
||||
Long ts = JsonParsers.pingTs(resp);
|
||||
if (ts == null || ts <= 0) {
|
||||
r.fail("Ping: сервер не вернул payload.ts, resp=" + resp);
|
||||
fail("Ping missing ts");
|
||||
}
|
||||
|
||||
r.ok("Ping: OK, ts=" + ts);
|
||||
}
|
||||
|
||||
private static void checkGetServerInfo(TestResult r, WsSession ws, Duration t) {
|
||||
String resp = ws.call("GetServerInfo", JsonBuilders.getServerInfo(), t);
|
||||
|
||||
if (JsonParsers.status(resp) != 200) {
|
||||
r.fail("GetServerInfo: ожидали status=200, resp=" + resp);
|
||||
fail("GetServerInfo unexpected status");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(JsonParsers.ok(resp))) {
|
||||
r.fail("GetServerInfo: ожидали ok=true, resp=" + resp);
|
||||
fail("GetServerInfo unexpected ok");
|
||||
}
|
||||
if (!JsonParsers.payloadIsObject(resp)) {
|
||||
r.fail("GetServerInfo: payload должен быть объектом, resp=" + resp);
|
||||
fail("GetServerInfo payload is not object");
|
||||
}
|
||||
|
||||
assertStringField(resp, "url", r);
|
||||
String version = assertStringField(resp, "version", r);
|
||||
assertStringField(resp, "physicalRegion", r);
|
||||
assertStringField(resp, "description", r);
|
||||
assertStringField(resp, "origin", r);
|
||||
assertStringField(resp, "extraInfo", r);
|
||||
|
||||
r.ok("GetServerInfo: OK, version=" + version);
|
||||
}
|
||||
|
||||
private static String assertStringField(String resp, String field, TestResult r) {
|
||||
String value = JsonParsers.payloadText(resp, field);
|
||||
if (value == null) {
|
||||
r.fail("GetServerInfo: отсутствует поле payload." + field + ", resp=" + resp);
|
||||
fail("GetServerInfo missing field: " + field);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
96
src/test/java/test/it/cases/IT_06_ChannelsApi.java
Normal file
96
src/test/java/test/it/cases/IT_06_ChannelsApi.java
Normal file
@ -0,0 +1,96 @@
|
||||
package test.it.cases;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import test.it.utils.TestConfig;
|
||||
import test.it.utils.json.JsonBuilders;
|
||||
import test.it.utils.json.JsonParsers;
|
||||
import test.it.utils.log.TestResult;
|
||||
import test.it.utils.ws.WsSession;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* IT_06_ChannelsApi
|
||||
*
|
||||
* Проверяет базовые happy-path сценарии для новых операций:
|
||||
* - ListSubscriptionsFeed
|
||||
* - GetChannelMessages
|
||||
* - GetMessageThread
|
||||
*/
|
||||
public class IT_06_ChannelsApi {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public static String run() {
|
||||
TestResult r = new TestResult("IT_06_ChannelsApi");
|
||||
Duration t = Duration.ofSeconds(8);
|
||||
|
||||
final String login = TestConfig.LOGIN();
|
||||
final String bchName = TestConfig.getBlockchainName(login);
|
||||
|
||||
try (WsSession ws = WsSession.open()) {
|
||||
String feedResp = ws.call("ListSubscriptionsFeed", JsonBuilders.listSubscriptionsFeed(login, 200), t);
|
||||
check200(r, feedResp, "ListSubscriptionsFeed");
|
||||
|
||||
int ownSize = JsonParsers.payloadArraySize(feedResp, "ownedChannels");
|
||||
if (ownSize < 0) {
|
||||
r.fail("ListSubscriptionsFeed: отсутствует ownedChannels array, resp=" + feedResp);
|
||||
fail("ownedChannels missing");
|
||||
}
|
||||
r.ok("ListSubscriptionsFeed: ownedChannels size=" + ownSize);
|
||||
|
||||
String chResp = ws.call("GetChannelMessages", JsonBuilders.getChannelMessages(bchName, 0, "", 200, "asc"), t);
|
||||
check200(r, chResp, "GetChannelMessages");
|
||||
|
||||
JsonNode chRoot = MAPPER.readTree(chResp);
|
||||
JsonNode messages = chRoot.path("payload").path("messages");
|
||||
if (!messages.isArray()) {
|
||||
r.fail("GetChannelMessages: payload.messages не массив, resp=" + chResp);
|
||||
fail("messages is not array");
|
||||
}
|
||||
r.ok("GetChannelMessages: messages size=" + messages.size());
|
||||
|
||||
if (messages.size() > 0) {
|
||||
JsonNode first = messages.get(0);
|
||||
int blockNumber = first.path("messageRef").path("blockNumber").asInt(-1);
|
||||
String blockHash = first.path("messageRef").path("blockHash").asText("");
|
||||
|
||||
if (blockNumber > 0 && !blockHash.isBlank()) {
|
||||
String threadResp = ws.call(
|
||||
"GetMessageThread",
|
||||
JsonBuilders.getMessageThread(bchName, blockNumber, blockHash, 20, 2, 50),
|
||||
t
|
||||
);
|
||||
check200(r, threadResp, "GetMessageThread");
|
||||
|
||||
JsonNode threadRoot = MAPPER.readTree(threadResp).path("payload");
|
||||
if (!threadRoot.path("ancestors").isArray() || !threadRoot.has("focus") || !threadRoot.path("descendants").isArray()) {
|
||||
r.fail("GetMessageThread: неверная форма payload, resp=" + threadResp);
|
||||
fail("thread payload shape invalid");
|
||||
}
|
||||
r.ok("GetMessageThread: payload shape OK");
|
||||
} else {
|
||||
r.ok("GetMessageThread: пропущено, у первого сообщения нет корректного ref");
|
||||
}
|
||||
} else {
|
||||
r.ok("GetMessageThread: пропущено, в канале нет сообщений");
|
||||
}
|
||||
|
||||
} catch (Throwable e) {
|
||||
r.fail("IT_06_ChannelsApi упал: " + e.getMessage());
|
||||
}
|
||||
|
||||
return r.summaryLine();
|
||||
}
|
||||
|
||||
private static void check200(TestResult r, String resp, String op) {
|
||||
int st = JsonParsers.status(resp);
|
||||
if (st != 200) {
|
||||
r.fail(op + ": ожидали status=200, получили " + st + ", resp=" + resp);
|
||||
fail(op + " status=" + st);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import test.it.cases.IT_02_Sessions;
|
||||
import test.it.cases.IT_03_AddBlock_NoAuth;
|
||||
import test.it.cases.IT_04_UserParams_NoAuth;
|
||||
import test.it.cases.IT_05_UserConnections;
|
||||
import test.it.cases.IT_06_ChannelsApi;
|
||||
import test.it.utils.log.TestLog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -56,6 +57,9 @@ public class IT_RunAllMain {
|
||||
String s5 = IT_05_UserConnections.run(); summaries.add(s5);
|
||||
if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||||
|
||||
String s6 = IT_06_ChannelsApi.run(); summaries.add(s6);
|
||||
if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||||
|
||||
return finish(summaries, failed);
|
||||
}
|
||||
|
||||
|
||||
@ -255,6 +255,61 @@ public final class JsonBuilders {
|
||||
""".formatted(requestId, login);
|
||||
}
|
||||
|
||||
public static String listSubscriptionsFeed(String login, int limit) {
|
||||
String requestId = TestIds.next("subsfeed");
|
||||
return """
|
||||
{
|
||||
"op": "ListSubscriptionsFeed",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"login": "%s",
|
||||
"limit": %d
|
||||
}
|
||||
}
|
||||
""".formatted(requestId, login, limit);
|
||||
}
|
||||
|
||||
public static String getChannelMessages(String ownerBlockchainName, int channelRootBlockNumber, String channelRootBlockHash, int limit, String sort) {
|
||||
String requestId = TestIds.next("chmsg");
|
||||
String hash = channelRootBlockHash == null ? "" : channelRootBlockHash;
|
||||
return """
|
||||
{
|
||||
"op": "GetChannelMessages",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"channel": {
|
||||
"ownerBlockchainName": "%s",
|
||||
"channelRootBlockNumber": %d,
|
||||
"channelRootBlockHash": "%s"
|
||||
},
|
||||
"limit": %d,
|
||||
"sort": "%s"
|
||||
}
|
||||
}
|
||||
""".formatted(requestId, ownerBlockchainName, channelRootBlockNumber, hash, limit, sort == null ? "asc" : sort);
|
||||
}
|
||||
|
||||
public static String getMessageThread(String blockchainName, int blockNumber, String blockHash, int depthUp, int depthDown, int limitChildrenPerNode) {
|
||||
String requestId = TestIds.next("thread");
|
||||
String hash = blockHash == null ? "" : blockHash;
|
||||
return """
|
||||
{
|
||||
"op": "GetMessageThread",
|
||||
"requestId": "%s",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "%s",
|
||||
"blockNumber": %d,
|
||||
"blockHash": "%s"
|
||||
},
|
||||
"depthUp": %d,
|
||||
"depthDown": %d,
|
||||
"limitChildrenPerNode": %d
|
||||
}
|
||||
}
|
||||
""".formatted(requestId, blockchainName, blockNumber, hash, depthUp, depthDown, limitChildrenPerNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись CreateAuthSession(v2):
|
||||
* preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce
|
||||
|
||||
@ -287,4 +287,32 @@ public final class JsonParsers {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static int payloadArraySize(String json, String field) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
JsonNode payload = root.get("payload");
|
||||
if (payload == null) return -1;
|
||||
JsonNode arr = payload.get(field);
|
||||
if (arr == null || !arr.isArray()) return -1;
|
||||
return arr.size();
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static int payloadNestedArraySize(String json, String objectField, String arrayField) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(json);
|
||||
JsonNode payload = root.get("payload");
|
||||
if (payload == null) return -1;
|
||||
JsonNode obj = payload.get(objectField);
|
||||
if (obj == null || !obj.isObject()) return -1;
|
||||
JsonNode arr = obj.get(arrayField);
|
||||
if (arr == null || !arr.isArray()) return -1;
|
||||
return arr.size();
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user