diff --git a/Dev_Docs/00_INDEX.md b/Dev_Docs/00_INDEX.md deleted file mode 100644 index b29d8ec..0000000 --- a/Dev_Docs/00_INDEX.md +++ /dev/null @@ -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`. diff --git a/Dev_Docs/01_Connection_and_Sessions.md b/Dev_Docs/01_Connection_and_Sessions.md deleted file mode 100644 index 8621a60..0000000 --- a/Dev_Docs/01_Connection_and_Sessions.md +++ /dev/null @@ -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) для каждого поля, чтобы клиенты не путались. diff --git a/Dev_Docs/02_Blockchain_Structure_and_Block_Types.md b/Dev_Docs/02_Blockchain_Structure_and_Block_Types.md deleted file mode 100644 index 7e38c75..0000000 --- a/Dev_Docs/02_Blockchain_Structure_and_Block_Types.md +++ /dev/null @@ -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. diff --git a/Dev_Docs/03_Addable_Blocks_Channels_Messages_Connections.md b/Dev_Docs/03_Addable_Blocks_Channels_Messages_Connections.md deleted file mode 100644 index b49d6db..0000000 --- a/Dev_Docs/03_Addable_Blocks_Channels_Messages_Connections.md +++ /dev/null @@ -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:: -> ` -- `channel_last_sync_at -> ` - -Ограничение: `users_params` сейчас хранит строки `param/value`, то есть сложные структуры лучше класть в JSON-строку с версией схемы. - -## 6) Чего не хватает прямо сейчас - -1. Нет RPC, который вернёт **список подписанных каналов** и счётчики сообщений. -2. Нет RPC, который вернёт **сообщения конкретного канала** (с пагинацией). -3. Нет RPC, который вернёт **подробный тред сообщения** (предки + все ответы) одним JSON. -4. Нет RPC «ленты событий» (кто лайкнул/ответил/подписался) как отдельного серверного канала. diff --git a/Dev_Docs/04_Query_Design_for_Subscriptions_Counters_and_Sync.md b/Dev_Docs/04_Query_Design_for_Subscriptions_Counters_and_Sync.md deleted file mode 100644 index 619e3c2..0000000 --- a/Dev_Docs/04_Query_Design_for_Subscriptions_Counters_and_Sync.md +++ /dev/null @@ -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.` = число (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. Пагинация и лимиты ответа (для больших тредов). diff --git a/Dev_Docs/05_Open_Questions_and_TODO.md b/Dev_Docs/05_Open_Questions_and_TODO.md deleted file mode 100644 index 8ac0139..0000000 --- a/Dev_Docs/05_Open_Questions_and_TODO.md +++ /dev/null @@ -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` (версия снимка данных), чтобы отличать повторный ответ от изменений после запроса. diff --git a/Dev_Docs/API/06_Channels_Read_API.md b/Dev_Docs/API/06_Channels_Read_API.md new file mode 100644 index 0000000..60d390c --- /dev/null +++ b/Dev_Docs/API/06_Channels_Read_API.md @@ -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` diff --git a/Dev_Docs/API/07_Channels_Feature_Runbook.md b/Dev_Docs/API/07_Channels_Feature_Runbook.md new file mode 100644 index 0000000..a623693 --- /dev/null +++ b/Dev_Docs/API/07_Channels_Feature_Runbook.md @@ -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": "" + }, + "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. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку. diff --git a/build.gradle b/build.gradle index ea9d854..0d8dc0c 100644 --- a/build.gradle +++ b/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 +} diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh new file mode 100755 index 0000000..53b08f6 --- /dev/null +++ b/deploy_shine-PWA.sh @@ -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 "Всё хорошо" diff --git a/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log b/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log new file mode 100644 index 0000000..fbef2a8 --- /dev/null +++ b/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log @@ -0,0 +1,1509 @@ +OpenAI Codex v0.116.0 (research preview) +-------- +workdir: /home/player/docker/shine-UI +model: gpt-5.4 +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: none +reasoning summaries: none +session id: 019d20b9-0f17-71d1-b203-e6ee9d881c58 +-------- +user +Project: static UI site for Shine, located at /home/player/docker/shine-UI. Task: modify the existing profile page. There are already 2 existing UI elements related to account status/features; do not create new duplicated elements if suitable ones already exist. Update their behavior so that when the user clicks each of these two existing elements, a menu, popup, modal, or dropdown appears that clearly explains that these controls are used to enable and disable the corresponding functions. The wording should feel natural in Russian UI. Keep the implementation simple and consistent with the current project style. If there is no existing modal system, create a lightweight one in plain HTML/CSS/JS appropriate for a static site. Make sure the interaction works in browser without backend. Update only what is necessary. At the end, print a short summary of changed files. +mcp startup: no servers +codex +Изменю профильную страницу точечно: сначала найду эти два существующих элемента статуса/функций и проверю, есть ли в проекте уже готовый механизм модалок или всплывающих меню, чтобы не дублировать UI. +exec +/bin/bash -lc 'rg --files js/pages js/components styles' in /home/player/docker/shine-UI succeeded in 0ms: +styles/main.css +styles/components.css +styles/layout.css +js/components/header.js +js/components/page-label.js +js/components/toolbar.js +js/pages/channel-view.js +js/pages/wallet-view.js +js/pages/messages-list.js +js/pages/settings-view.js +js/pages/chat-view.js +js/pages/notifications-view.js +js/pages/profile-view.js +js/pages/network-view.js +js/pages/channels-list.js +exec +/bin/bash -lc 'rg -n "профил|статус|функц|toggle|switch|premium|verified|online|visibility|notifications|privacy|feature|status" js/pages js/components styles index.html' in /home/player/docker/shine-UI succeeded in 0ms: +js/components/toolbar.js:7: { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, +js/pages/wallet-view.js:9: let statusText = 'Данные демонстрационные'; +js/pages/wallet-view.js:11: const status = document.createElement('p'); +js/pages/wallet-view.js:12: status.className = 'meta-muted'; +js/pages/wallet-view.js:15: statusText = text; +js/pages/wallet-view.js:16: status.textContent = statusText; +js/pages/wallet-view.js:67: updateStatus('Демо-функция: перевод будет добавлен позже'); +js/pages/wallet-view.js:71: updateStatus('Демо-функция: пополнение будет добавлено позже'); +js/pages/wallet-view.js:74: updateStatus(statusText); +js/pages/wallet-view.js:76: screen.append(card, actions, status); +js/pages/notifications-view.js:2:import { notifications } from '../mock-data.js'; +js/pages/notifications-view.js:5:export const pageMeta = { id: 'notifications-view', title: 'Уведомления' }; +js/pages/notifications-view.js:8: const active = state.notificationsTab; +js/pages/notifications-view.js:9: const items = notifications[active] || []; +js/pages/notifications-view.js:29: +js/pages/notifications-view.js:30: +js/pages/notifications-view.js:39: state.notificationsTab = btn.dataset.tab; +codex +Нашёл профильный экран. Сейчас открою его и связанные стили, чтобы определить, какие именно два элемента уже используются и можно ли встроить простую подсказку без отдельной тяжёлой инфраструктуры. +exec +/bin/bash -lc "sed -n '1,240p' js/pages/profile-view.js" in /home/player/docker/shine-UI succeeded in 0ms: +import { renderHeader } from '../components/header.js'; +import { profile } from '../mock-data.js'; + +export const pageMeta = { id: 'profile-view', title: 'Профиль' }; + +export function render({ navigate }) { + 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'); +exec +/bin/bash -lc "sed -n '1,260p' styles/components.css" in /home/player/docker/shine-UI succeeded in 0ms: +.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; +exec +/bin/bash -lc "sed -n '1,260p' styles/main.css" in /home/player/docker/shine-UI succeeded in 0ms: +: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; +} + +codex +В профиле нашлись два уже существующих элемента: два бейджа в правом верхнем блоке карточки. Глобальной системы модалок нет, поэтому добавлю лёгкий локальный попап для этой страницы и привяжу его именно к этим двум существующим бейджам без новых дублирующих контролов. +exec +/bin/bash -lc "sed -n '1,220p' js/mock-data.js" in /home/player/docker/shine-UI succeeded in 0ms: +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 directMessages = [ + { + id: 'u1', +exec +/bin/bash -lc "sed -n '1,220p' js/app.js" in /home/player/docker/shine-UI succeeded in 0ms: +import { navigate, getRoute } from './router.js'; +import { renderToolbar } from './components/toolbar.js'; +import { renderPageLabel } from './components/page-label.js'; + +import * as profileView from './pages/profile-view.js'; +import * as walletView from './pages/wallet-view.js'; +import * as settingsView from './pages/settings-view.js'; +import * as messagesList from './pages/messages-list.js'; +import * as chatView from './pages/chat-view.js'; +import * as channelsList from './pages/channels-list.js'; +import * as channelView from './pages/channel-view.js'; +import * as networkView from './pages/network-view.js'; +import * as notificationsView from './pages/notifications-view.js'; + +const routes = { + 'profile-view': profileView, + 'wallet-view': walletView, + 'settings-view': settingsView, + 'messages-list': messagesList, + 'chat-view': chatView, +exec +/bin/bash -lc "sed -n '1,220p' js/router.js" in /home/player/docker/shine-UI succeeded in 0ms: +const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view']; + +export function getRoute() { + const raw = window.location.hash.replace(/^#\/?/, ''); + if (!raw) { + return { pageId: 'profile-view', params: {} }; + } + + const [pageId, dynamicId] = raw.split('/'); + + if (pageId === 'chat-view') { + return { pageId, params: { chatId: dynamicId || '' } }; + } + + if (pageId === 'channel-view') { + return { pageId, params: { channelId: dynamicId || '' } }; + } + + return { pageId, params: {} }; +} +codex +Вношу минимальные правки в профиль: превращу оба существующих бейджа в кликабельные триггеры и добавлю один переиспользуемый попап с русскими пояснениями для каждого из них. +file update +M /home/player/docker/shine-UI/js/pages/profile-view.js +@@ -6,2 +6,13 @@ + export function render({ navigate }) { ++ const badgeHelp = { ++ official: { ++ title: 'Официальный аккаунт', ++ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', ++ }, ++ shine: { ++ title: 'Сияющий', ++ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', ++ }, ++ }; ++ + const screen = document.createElement('section'); +@@ -25,4 +36,4 @@ +
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++ ++ +
+@@ -41,3 +52,53 @@ + +- screen.append(card); ++ const modal = document.createElement('div'); ++ modal.className = 'profile-help-modal'; ++ modal.hidden = true; ++ modal.innerHTML = ` ++
++ ++ `; ++ ++ const titleEl = modal.querySelector('#profile-help-title'); ++ const textEl = modal.querySelector('.profile-help-text'); ++ ++ 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; ++ } ++ ++ 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; +apply_patch(auto_approved=true) exited 0 in 33ms: +Success. Updated the following files: +M /home/player/docker/shine-UI/js/pages/profile-view.js +file update: +diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js +index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 +--- a//home/player/docker/shine-UI/js/pages/profile-view.js ++++ b//home/player/docker/shine-UI/js/pages/profile-view.js +@@ -4,6 +4,17 @@ + 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'; + +@@ -23,8 +34,8 @@ +
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++ ++ +
+
+
+@@ -39,6 +50,56 @@ +
+ `; + +- screen.append(card); ++ const modal = document.createElement('div'); ++ modal.className = 'profile-help-modal'; ++ modal.hidden = true; ++ modal.innerHTML = ` ++
++ ++ `; ++ ++ const titleEl = modal.querySelector('#profile-help-title'); ++ const textEl = modal.querySelector('.profile-help-text'); ++ ++ 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; ++ } ++ ++ 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; + } + +file update: +diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js +index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 +--- a//home/player/docker/shine-UI/js/pages/profile-view.js ++++ b//home/player/docker/shine-UI/js/pages/profile-view.js +@@ -4,6 +4,17 @@ + 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'; + +@@ -23,8 +34,8 @@ +
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++ ++ +
+
+
+@@ -39,6 +50,56 @@ +
+ `; + +- screen.append(card); ++ const modal = document.createElement('div'); ++ modal.className = 'profile-help-modal'; ++ modal.hidden = true; ++ modal.innerHTML = ` ++
++ ++ `; ++ ++ const titleEl = modal.querySelector('#profile-help-title'); ++ const textEl = modal.querySelector('.profile-help-text'); ++ ++ 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; ++ } ++ ++ 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; + } + +file update +M /home/player/docker/shine-UI/styles/components.css +@@ -89,2 +89,6 @@ + ++.badge.profile-badge-trigger { ++ cursor: pointer; ++} ++ + .badge.alt { +@@ -95,2 +99,36 @@ + ++.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; ++} ++ + .list-item { +apply_patch(auto_approved=true) exited 0 in 32ms: +Success. Updated the following files: +M /home/player/docker/shine-UI/styles/components.css +file update: +diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js +index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 +--- a//home/player/docker/shine-UI/js/pages/profile-view.js ++++ b//home/player/docker/shine-UI/js/pages/profile-view.js +@@ -4,6 +4,17 @@ + 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'; + +@@ -23,8 +34,8 @@ +
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++ ++ +
+
+
+@@ -39,6 +50,56 @@ +
+ `; + +- screen.append(card); ++ const modal = document.createElement('div'); ++ modal.className = 'profile-help-modal'; ++ modal.hidden = true; ++ modal.innerHTML = ` ++
++ ++ `; ++ ++ const titleEl = modal.querySelector('#profile-help-title'); ++ const textEl = modal.querySelector('.profile-help-text'); ++ ++ 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; ++ } ++ ++ 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; + } +diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css +index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f +--- a//home/player/docker/shine-UI/styles/components.css ++++ b//home/player/docker/shine-UI/styles/components.css +@@ -87,12 +87,50 @@ + 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; ++} ++ + .list-item { + display: grid; + grid-template-columns: 44px 1fr auto; + +file update: +diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js +index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 +--- a//home/player/docker/shine-UI/js/pages/profile-view.js ++++ b//home/player/docker/shine-UI/js/pages/profile-view.js +@@ -4,6 +4,17 @@ + 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'; + +@@ -23,8 +34,8 @@ +
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++ ++ +
+
+
+@@ -39,6 +50,56 @@ +
+ `; + +- screen.append(card); ++ const modal = document.createElement('div'); ++ modal.className = 'profile-help-modal'; ++ modal.hidden = true; ++ modal.innerHTML = ` ++
++ ++ `; ++ ++ const titleEl = modal.querySelector('#profile-help-title'); ++ const textEl = modal.querySelector('.profile-help-text'); ++ ++ 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; ++ } ++ ++ 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; + } +diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css +index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f +--- a//home/player/docker/shine-UI/styles/components.css ++++ b//home/player/docker/shine-UI/styles/components.css +@@ -87,12 +87,50 @@ + 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; ++} ++ + .list-item { + display: grid; + grid-template-columns: 44px 1fr auto; + +codex +Добавлю ещё одну небольшую доработку для надёжности: попап должен закрываться по `Esc`, поэтому дам диалогу фокус при открытии, без глобальных обработчиков и лишней инфраструктуры. +file update +M /home/player/docker/shine-UI/js/pages/profile-view.js +@@ -57,3 +57,3 @@ +
+-