Дизайн Артёма

This commit is contained in:
AidarKC
2026-09-22 14:09:02 +03:00
parent cae64639bd
commit 8b12760dde
40 changed files with 3114 additions and 5008 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.12.21
client.version=1.12.22
server.version=1.10.8
+290
View File
@@ -0,0 +1,290 @@
# SHiNE — дизайн приложения
Версия 0.2 · 22 сентября 2026.
Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах.
Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом.
## 1. Задача и границы
Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки.
Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна.
Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома.
Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист.
## 2. Принципы для всего приложения
1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности.
2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние.
3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана.
4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты.
5. Редкие и технические действия помещаются в меню, но остаются доступными.
6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст.
7. Метки, иконки и расположение действий повторяются во всех разделах.
8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе.
## 3. Принятые решения
| Решение пользователя | Правило для реализации |
| --- | --- |
| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки |
| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния |
| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент |
| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой |
| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу |
| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос |
| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас |
| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием |
| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба |
Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется.
## 4. Базовые параметры
Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места.
| Параметр | Целевое значение |
| --- | --- |
| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px |
| Имя автора | 14 px / 20 px, вес 600 |
| Вторичная информация | 12 px / 18 px |
| Заголовок корневого экрана | 24 px / 30 px, вес 600 |
| Название канала в компактной панели | 17 px / 22 px, вес 600 |
| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 |
| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px |
| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px |
| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу |
| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе |
| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки |
| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха |
| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края |
| Контраст текста | Цель WCAG AA; проверить утверждённую палитру |
| Анимация | 150–200 мс, отключение при reduced motion |
### 4.1. Семантические цвета двух тем
Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации.
| Роль | Ночь | День | Где используется |
| --- | --- | --- | --- |
| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация |
| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла |
| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение |
| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст |
| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки |
| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк |
| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля |
| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация |
| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке |
| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса |
| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка |
| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления |
| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением |
| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением |
| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом |
Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации.
Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры.
`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости.
### 4.2. Выбор темы
Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения.
Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала.
## 5. Список каналов
Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность.
Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью.
Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору».
Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты.
Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически.
## 6. Канал и сообщения
### 6.1. Компактная шапка
Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале.
Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного.
Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий.
### 6.2. Публикация
Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки.
У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`.
Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста.
«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты.
Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием.
Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов.
## 7. Ветка обсуждения
Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных.
Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности.
Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу.
## 8. Мобильный редактор ответа
Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа.
Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты.
При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала.
Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее.
Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран.
Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе.
Состояния отправки:
- Пустой текст без вложений: кнопка неактивна.
- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа.
- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение.
- Отправка: индикатор, блокировка повторного нажатия.
- Ошибка: понятная причина, повтор, сохранённый текст.
- Успех: новый ответ и восстановленный контекст обсуждения.
- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape.
В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу.
Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю.
## 9. Что подтверждено кодом
| Возможность | Где прочитано | Решение макета |
| --- | --- | --- |
| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно |
| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки |
| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца |
| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале |
| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа |
| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство |
| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта |
| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом |
Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию.
## 10. Состояния для следующей итерации
| Область | Нужные дополнительные макеты |
| --- | --- |
| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов |
| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название |
| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип |
| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке |
| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса |
| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion |
## 11. Общие компоненты для единообразия
| Компонент | Единое правило |
| --- | --- |
| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного |
| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области |
| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия |
| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled |
| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку |
| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка |
| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок |
| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию |
| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект |
| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке |
| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине |
| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера |
| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно |
| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке |
| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления |
| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста |
Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом.
Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран.
### 11.1. Общая адаптация
Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту.
При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует.
Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения.
### 11.2. Применение к остальным разделам
| Раздел | Что наследует | Что требует отдельного проектирования |
| --- | --- | --- |
| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки |
| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности |
| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта |
| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления |
| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления |
| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки |
Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок.
## 12. Допущения и отложенные решения
Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop.
До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически.
Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса.
## 13. Перенос в приложение и критерии готовности
На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения.
Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора.
Матрица приёмки для первого переноса:
| Проверка | Ожидаемый результат |
| --- | --- |
| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте |
| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются |
| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню |
| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал |
| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки |
| Оглавление | Доступно через меню, включая пустое состояние |
| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой |
| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке |
| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст |
| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна |
| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком |
| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются |
| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы |
| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется |
| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается |
Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне.
Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы.
## 14. Журнал решений
| Дата | Версия | Решение |
| --- | --- | --- |
| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения |
| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята |
| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения |
+46
View File
@@ -0,0 +1,46 @@
# Первый перенос дизайна каналов
22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2.
## Реализовано
- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу.
- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск.
- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне.
- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись.
- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён.
- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются.
- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану.
- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись.
## Проверено автоматически
Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует.
- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход.
- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup.
- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose.
- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса.
- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`.
- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade.
Воспроизводимый запуск из корня продукта (Node 24):
```bash
check_dir=$(mktemp -d)
npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss
SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs
git diff --check
```
Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются.
## Границы и оставшаяся приёмка
API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён.
Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются.
Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты.
Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался.
+41
View File
@@ -0,0 +1,41 @@
# Дизайн SHiNE
Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню.
- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки.
- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора.
Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся.
## Как посмотреть
Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`.
Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально.
Отдельные варианты:
1. [Чистый монохром](channels-v1/index.html?variant=mono).
2. [Тёплый SHiNE](channels-v1/index.html?variant=warm).
3. [Ночной SHiNE](channels-v1/index.html?variant=night).
4. [Компактный клуб](channels-v1/index.html?variant=club).
## Что попробовать
1. Нажать на канал и открыть обсуждение через значок комментариев.
2. Ответить на исходное сообщение или конкретному собеседнику.
3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы.
4. Прикрепить файл, удалить вложение, отправить текст или только вложение.
5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно.
6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации.
7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения.
Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета.
## Границы
Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer.
## Проверка
Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения.
+35
View File
@@ -0,0 +1,35 @@
# Проверка HTML-мокапов
Дата: 22.09.2026.
## Выполнено
- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен.
- CSS разобран PostCSS без ошибок синтаксиса.
- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации.
- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов.
- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого.
- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`.
- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно.
- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись.
Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись.
## Ограничения проверки
Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером.
Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место.
Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись.
## Уточнение документа 0.2
22.09.2026, после выбора пользователем направления:
- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах.
- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню.
- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса.
- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1.
- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет.
- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания.
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE — четыре направления UI</title>
<link rel="stylesheet" href="mockups.css">
<script src="mockups.js" defer></script>
</head>
<body>
<header class="studio-header">
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark"></span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
<span class="draft-label">Концепции · сентябрь 2026</span>
</header>
<main>
<section class="intro">
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
</section>
<section class="review-bar" aria-label="Управление просмотром">
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
<button class="review-button selected" data-screen="list" aria-pressed="true">01 Каналы</button>
<button class="review-button" data-screen="feed" aria-pressed="false">02 Сообщения</button>
<button class="review-button" data-screen="thread" aria-pressed="false">03 Ветка</button>
<button class="review-button" data-screen="reply" aria-pressed="false">04 Ответ</button>
</div>
<div class="review-options"><label>Роль <select id="role"><option value="reader">Читатель</option><option value="owner">Владелец</option></select></label><label class="keyboard-option"><input type="checkbox" id="keyboard"> Макет клавиатуры</label><a class="compare-link" href="index.html">Все варианты ↗</a></div>
</section>
<div id="gallery" class="gallery"></div>
<section class="decision-note"><span class="eyebrow">С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ</span><h2>Сначала удобство. Затем характер.</h2><div class="decision-grid"><p><b>01 / Читаемость</b>Где комфортнее читать длинный пост и замечать новый ответ?</p><p><b>02 / Плотность</b>Нужны просторные сообщения или больше содержимого на одном экране?</p><p><b>03 / Окно ответа</b>Полный экран или лист поверх ветки? Проверьте с макетом клавиатуры.</p></div><p class="disclaimer">Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. <a href="../DESIGN.md">Дизайн-документ ↗</a></p></section>
</main>
<footer class="studio-footer"><span>SHiNE / пространство для общения</span><span>Рабочий материал · не финальный дизайн</span></footer>
</body>
</html>
+212
View File
@@ -0,0 +1,212 @@
* { box-sizing: border-box; }
:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; }
body { margin: 0; }
button, input, textarea, select { font: inherit; }
button, a, select { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .4; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; }
a { color: inherit; }
.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
.brand-spark { font-size: 36px; font-weight: 400; }
.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; }
.draft-label { font-size: 12px; color: #63675f; }
main { padding: 0 42px; max-width: 1900px; margin: auto; }
.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; }
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; }
h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; }
.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; }
.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; }
.intro-note > span { font-size: 38px; letter-spacing: -2px; }
.intro-note p { font-size: 12px; margin-top: 10px; }
.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; }
.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; }
.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; }
.review-button.selected { background: #292d27; color: white; }
.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; }
.review-options label { display: flex; align-items: center; gap: 6px; }
.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; }
.compare-link { display: none; }
.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; }
.concept { min-width: 0; }
.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; }
.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; }
.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; }
.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; }
.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; }
.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; }
.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; }
.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; }
.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; }
.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; }
.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; }
.status-icons .icon { height: 16px; width: 16px; }
.phone button { color: inherit; }
.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; }
.icon-btn:hover { background: var(--soft); }
.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); }
.header-text { flex: 1; min-width: 0; }
.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-text small { color: var(--muted); font-size: 10px; }
.list-header { padding-left: 22px; }
.list-header h3 { font-size: 25px; letter-spacing: -.9px; }
.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; }
.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; }
.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); }
.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; }
.search-field input::placeholder { color: var(--muted); }
.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; }
.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); }
.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); }
.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); }
.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
.channel-row:hover { background: var(--soft); }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; }
.avatar.city { background: #ded3bb; color: #635039; }
.avatar.blue { background: #bac5d9; color: #354663; }
.avatar.rose { background: #d9bcbb; color: #704444; }
.avatar.green { background: #b9cdc0; color: #345747; }
.avatar.violet { background: #cbc2d9; color: #56476c; }
.avatar.self { background: var(--soft); color: var(--accent); }
.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; }
.channel-copy { min-width: 0; flex: 1; }
.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; }
.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; }
.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; }
.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; }
.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); }
.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; }
.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); }
.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); }
.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; }
.text-button .icon { width: 16px; height: 16px; }
.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; }
.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; }
.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); }
.post-header { display: flex; align-items: center; gap: 9px; }
.post-header .avatar { width: 34px; height: 34px; font-size: 11px; }
.author { min-width: 0; flex: 1; font-size: 12px; }
.author strong { font-weight: 650; }
.author small { color: var(--muted); font-size: 10px; display: block; }
.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); }
.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; }
.post-title { font-weight: 600; }
.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; }
.landscape svg { width: 100%; height: 100%; display: block; }
.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; }
.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); }
.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); }
.action[aria-pressed="true"] { color: #dc7477; }
.action[aria-pressed="true"] .icon { fill: currentColor; }
.reply-link { margin-left: auto; font-size: 10px; }
.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; }
.attachment > .icon { color: var(--accent); }
.attachment strong { font-size: 11px; display: block; }
.attachment small { color: var(--muted); display: block; font-size: 10px; }
.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; }
.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; }
.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; }
.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
.thread-heading strong { color: var(--text); }
.reply { display: flex; padding: 16px 18px 0; gap: 10px; }
.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
.reply .avatar { width: 32px; height: 32px; font-size: 10px; }
.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; }
.reply small { color: var(--muted); font-size: 10px; }
.reply strong { font-size: 12px; }
.reply .post-actions { gap: 16px; }
.reply-context { color: var(--accent); font-size: 10px; }
.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; }
.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; }
.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; }
.sheet.full { height: 100%; border-radius: 0; }
.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; }
.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; }
.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; }
.sheet-header .expand { color: var(--muted); }
.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; }
.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; }
.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); }
.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; }
.source-preview strong { font-size: 12px; }
.source-preview small { color: var(--muted); font-size: 10px; }
.editor-row { display: flex; gap: 10px; }
.editor-main { flex: 1; min-width: 0; }
.editor-main strong { display: block; font-size: 12px; margin: 2px 0; }
.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; }
.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; }
.editor-input::placeholder { color: var(--muted); }
.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); }
.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); }
.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; }
.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; }
.phone .submit { color: var(--on-accent); }
.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; }
.editor-attachment span { flex: 1; overflow-wrap: anywhere; }
.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); }
.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; }
.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; }
.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; }
.keys .space { max-width: 150px; flex: 5; font-size: 10px; }
.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; }
.menu-sheet { min-height: 0; padding-bottom: 24px; }
.menu-content { padding: 10px 20px; }
.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); }
.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; }
.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; }
.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; }
.concept-notes strong { color: #33392f; font-weight: 600; }
.swatches { display: flex; gap: 5px; margin-bottom: 12px; }
.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; }
.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; }
.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; }
.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; }
.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; }
.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; }
.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; }
.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; }
.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; }
/* Варианты отличаются также плотностью, иерархией и формой редактора. */
.phone[data-theme="mono"] .post { padding-left: 18px; }
.phone[data-theme="mono"] .post-content { margin-left: 43px; }
.phone[data-theme="mono"] .landscape { height: 128px; }
.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); }
.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; }
.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; }
.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; }
.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; }
.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; }
.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; }
.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); }
.phone[data-theme="night"] .composer-trigger { border-radius: 13px; }
.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; }
.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; }
.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; }
.phone[data-theme="club"] .channel-copy small { display: none; }
.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; }
.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; }
.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); }
.phone[data-theme="club"] .post { padding: 12px 16px 5px; }
.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; }
.phone[data-theme="club"] .landscape { height: 108px; }
.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; }
.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; }
.phone[data-theme="club"] .reply { padding-top: 12px; }
.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; }
.phone[data-theme="club"] .composer-trigger { border-radius: 10px; }
.gallery.single { display: block; max-width: 410px; margin: auto; }
.single .concept-description { min-height: 0; }
body.focused .compare-link { display: inline; }
@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } }
@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } }
@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } }
@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } }
+246
View File
@@ -0,0 +1,246 @@
/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */
'use strict';
const icons = {
back: '<path d="m14 5-7 7 7 7M7 12h14"/>',
close: '<path d="m6 6 12 12M6 18 18 6"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
heart: '<path d="M20.8 4.8a5.5 5.5 0 0 0-7.8 0L12 6l-1.1-1.2a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"/>',
chat: '<path d="M21 11.5a9 9 0 0 1-9 9 9.7 9.7 0 0 1-4-.9L3 21l1.4-4.7A9 9 0 1 1 21 11.5Z"/>',
share: '<path d="m21 3-7 18-4-8-8-3 19-7ZM10 13 21 3"/>',
channels: '<rect x="3" y="4" width="18" height="13" rx="3"/><path d="m8 21 4-4 4 4M7 8h10M7 12h6"/>',
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
book: '<path d="M12 5v16M3 4c4-1 6-1 9 1 3-2 5-2 9-1v15c-4-1-6-1-9 2-3-3-5-3-9-2Z"/>',
file: '<path d="M14 2H5v20h14V7l-5-5ZM14 2v6h5M8 13h8M8 17h5"/>',
attach: '<path d="m9 17 9-9a3 3 0 0 0-4-4L4 14a5 5 0 0 0 7 7L21 11M7 14l8-8"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
expand: '<path d="M14 3h7v7M21 3l-7 7M10 21H3v-7M3 21l7-7"/>',
check: '<path d="m5 12 4 4L19 6"/>',
edit: '<path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
wifi: '<path d="M3 8a15 15 0 0 1 18 0M6 12a10 10 0 0 1 12 0M9 16a5 5 0 0 1 6 0M12 20h.01"/>',
battery: '<rect x="2" y="6" width="18" height="12" rx="2"/><path d="M23 10v4M5 9h11v6H5Z"/>',
};
const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.chat}</svg>`;
const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
const button = (action, label, name, extra = '') => `<button class="icon-btn" data-action="${action}" aria-label="${label}" title="${label}" ${extra}>${icon(name)}</button>`;
const concepts = [
{ id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. <strong>Самая нейтральная отправная точка.</strong>' },
{ id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается <strong>округлым листом снизу.</strong>' },
{ id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. <strong>Мой кандидат на развитие:</strong> узнаваемый, но сдержанный.' },
{ id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор <strong>на весь экран</strong>, без потери контекста.' },
];
const channels = [
{ title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true },
{ title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true },
{ title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true },
{ title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true },
{ title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false },
{ title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false },
];
let role = 'reader';
let showKeyboard = false;
const states = new Map();
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
function header(s) {
if (s.screen === 'list') return `<header class="app-header list-header"><span class="mini-brand">✳</span><div class="header-text"><h3>Каналы</h3></div>${button('new-channel','Создать канал','plus')}</header>`;
return `<header class="app-header">${button('back','Назад','back')}<div class="header-text"><h3>${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}</h3><small>${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}</small></div>${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}</header>`;
}
function nav() {
return `<nav class="bottom-nav" aria-label="Разделы приложения">${[['chat','Личные'],['channels','Каналы'],['people','Связи'],['bell','Уведомления'],['user','Профиль']].map(([i,label])=>`<button class="nav-item" data-action="${i==='channels'?'list':'outside'}" ${i==='channels'?'aria-current="page"':''}>${icon(i)}<span>${label}</span></button>`).join('')}</nav>`;
}
function rows(s) {
const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase()));
return filtered.length ? filtered.map(c=>`<button class="channel-row" data-action="open-channel" data-index="${channels.indexOf(c)}"><span class="avatar ${c.color}">${c.symbol}</span><span class="channel-copy"><strong>${c.title}</strong><small>@${c.owner} / ${c.slug}</small><p>${c.preview}</p></span><span class="channel-tail"><span>${c.time}</span>${c.unread?`<span class="badge" aria-label="${c.unread} непрочитанных">${c.unread}</span>`:''}</span></button>`).join('') : '<p class="empty">Каналы не найдены.<br>Попробуйте другое название или логин.</p>';
}
function list(s) {
return `<label class="search-field">${icon('search')}<input data-search aria-label="Поиск каналов" placeholder="Название или @автор" value="${escapeText(s.query)}"></label><div class="filter-tabs" role="group" aria-label="Фильтр каналов">${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>`<button class="filter" data-action="filter" data-value="${value}" aria-pressed="${s.filter===value}">${label}</button>`).join('')}</div><div class="section-label"><span>${s.filter==='mine'?'Ваши каналы':'Ваше пространство'}</span><span>SHiNE</span></div><div class="channel-rows">${rows(s)}</div><p class="list-tip">Хорошие разговоры начинаются<br>с общих интересов.</p>`;
}
function actions(s, id, count, replies) {
const liked = s.likes.has(id);
replies = 2 + s.replies.filter(reply => reply.post === id).length;
return `<div class="post-actions"><button class="action" data-action="like" data-post="${id}" aria-label="Нравится" aria-pressed="${liked}">${icon('heart')}<span>${count+(liked?1:0)}</span></button><button class="action" data-action="thread" data-post="${id}" aria-label="Открыть ${replies} ответов">${icon('chat')}<span>${replies}</span></button><button class="action" data-action="share" aria-label="Поделиться">${icon('share')}</button><button class="action reply-link" data-action="reply" data-post="${id}">Ответить</button></div>`;
}
function post(s, second = false) {
return `<article class="post"><div class="post-header"><span class="avatar city">АК</span><div class="author"><strong>${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}</strong><small>${second?'вчера, 19:10':'12 минут назад'}</small></div><button class="icon-btn more" data-action="post-menu" aria-label="Действия с сообщением">${icon('more')}</button></div><div class="post-content">${second?'<p>Собрала наши любимые места в один список. Добавляйте свои находки в ответах!</p><div class="attachment">'+icon('file')+'<div><strong>Места для прогулок.pdf</strong><small>PDF · 552 КБ</small></div></div>':`<p class="post-title">Маленькие открытия</p><p>${mainText}</p>${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}</div></article>`;
}
function feed(s) {
return `<section class="channel-intro"><p>Замечаем красоту рядом. Делимся местами,<br>историями и маленькими открытиями.</p><div class="channel-tools"><button class="text-button" data-action="contents">${icon('book')} Оглавление</button><button class="pill" data-action="subscribe">${s.subscribed?'Вы подписаны':'Подписаться'}</button></div></section><div class="unread-line">Новые сообщения</div>${post(s)}${post(s,true)}`;
}
function replyRow(name, initials, color, text, target='') {
return `<article class="reply"><span class="avatar ${color}">${initials}</span><div class="reply-body">${target?`<div class="reply-context">В ответ ${escapeText(target)}</div>`:''}<strong>${escapeText(name)}</strong> <small>· сейчас</small><p>${escapeText(text)}</p><div class="post-actions"><button class="action" data-action="reply-to" data-author="${escapeText(name)}" data-text="${escapeText(text)}">${icon('chat')} Ответить</button></div></div></article>`;
}
function thread(s) {
const replies=s.replies.filter(r=>r.post===s.activePost);
const second=s.activePost==='second';
return `${post(s,second)}<div class="thread-heading"><strong>Ответы · ${2+replies.length}</strong><span>По порядку</span></div>${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`;
}
function composer(s) {
if (s.screen==='list') return '';
if (s.screen==='feed' && role==='reader') return '';
return `<div class="composer-bar"><button class="composer-trigger ${s.screen==='feed'?'primary':''}" data-action="${s.screen==='feed'?'new-post':'reply'}"><span>${s.screen==='feed'?'Написать в канал':'Ответить '+s.channel.owner+'…'}</span>${icon(s.screen==='feed'?'plus':'edit')}</button></div>`;
}
function render(s) {
const previousView = s.phone.querySelector('.view');
s.scrolls ||= {};
if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop;
s.phone.innerHTML = `<div class="status-bar"><span>9:41</span><span class="status-icons">${icon('wifi')}${icon('battery')}</span></div>${header(s)}<div class="view">${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}</div>${composer(s)}${nav()}`;
if(s.screen==='feed') for(const text of s.posts) {
const item=document.createElement('article'); item.className='post';
const author=document.createElement('strong'); author.textContent='Вы · сейчас';
const body=document.createElement('p'); body.textContent=text; item.append(author,body);
s.phone.querySelector('.view').append(item);
}
s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0;
s.renderedScreen = s.screen;
if (s.editor) mountEditor(s);
}
function keyboard() {
return `<div class="keyboard" aria-hidden="true"><div class="keyboard-label">Макет клавиатуры · не интерактивный</div>${['йцукенгшщзх','фывапролджэ','ячсмитьбю'].map(row=>`<div class="keys">${[...row].map(c=>`<span>${c}</span>`).join('')}</div>`).join('')}<div class="keys"><span class="wide">123</span><span class="space">Русский</span><span class="wide">↵</span></div></div>`;
}
function mountEditor(s) {
const mode = s.editor;
const full = s.id==='mono'||s.id==='club'||s.expanded;
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.innerHTML = `<section class="sheet ${full?'full':''}" role="dialog" aria-modal="true" aria-label="${mode==='post'?'Новое сообщение':'Ответ на сообщение'}" tabindex="-1">${full?'':'<div class="sheet-handle"></div>'}<div class="sheet-header">${button('close-editor','Закрыть и сохранить черновик','close')}<h4>${mode==='post'?'Новое сообщение':'Ответ'}</h4>${button('expand','Развернуть или свернуть редактор','expand','aria-pressed="'+full+'"')}</div><div class="editor-scroll">${mode==='post'?'':`<div class="source-preview"><span class="avatar city">${s.replyTo===s.channel.owner?'АК':escapeText(s.replyTo.slice(0,2).toUpperCase())}</span><div><strong>${escapeText(s.replyTo)}</strong><small> · ${escapeText(s.channel.title)}</small><p>${escapeText(s.replyText)}</p></div></div>`}<div class="editor-row"><span class="avatar self">ВЫ</span><div class="editor-main"><strong>Вы</strong><small>${mode==='post'?'Публикация в «'+escapeText(s.channel.title)+'»':'Ответ '+escapeText(s.replyTo)+' · виден в обсуждении'}</small><textarea class="editor-input" maxlength="2000" aria-label="Текст ${mode==='post'?'сообщения':'ответа'}" placeholder="${mode==='post'?'О чём хотите рассказать?':'Продолжите разговор…'}">${escapeText(s.drafts[mode])}</textarea><div class="selected-attachment">${attachmentMarkup(s)}</div></div></div></div><div class="editor-footer">${button('attach','Прикрепить файл','attach')}<input type="file" hidden data-file><span class="char-count">${s.drafts[mode].length} / 2000</span><button class="submit" data-action="send" ${!s.drafts[mode].trim()&&!s.attachments[mode]?'disabled':''}>${mode==='post'?'Опубликовать':'Ответить'}</button></div>${showKeyboard?keyboard():''}</section>`;
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true);
s.phone.append(overlay);
overlay.querySelector('.sheet').focus({preventScroll:true});
}
function attachmentMarkup(s) {
const name = s.attachments[s.editor];
return name?`<div class="editor-attachment">${icon('file')}<span>${escapeText(name)}</span>${button('remove-attachment','Удалить вложение','close')}</div>`:'';
}
function removeOverlay(s) {
s.phone.querySelector('.overlay')?.remove();
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false);
}
function closeEditor(s) {
s.editor = null;
removeOverlay(s);
if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true});
else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true});
}
function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) {
if (s.editor) closeEditor(s);
s.editor=mode;
s.replyTo=author;
s.replyText=text;
s.returnFocus=trigger;
s.expanded=false;
mountEditor(s);
}
function toast(s, message) {
s.phone.querySelector('.toast')?.remove();
clearTimeout(s.toastTimer);
const el=document.createElement('div');
el.className='toast'; el.setAttribute('role','status'); el.textContent=message;
s.phone.append(el);
s.toastTimer=setTimeout(()=>el.remove(),3500);
}
function menu(s, title, contents, trigger) {
removeOverlay(s);
s.returnFocus=trigger;
const el=document.createElement('div'); el.className='overlay';
el.innerHTML=`<section class="sheet menu-sheet" role="dialog" aria-modal="true" aria-label="${escapeText(title)}" tabindex="-1"><div class="sheet-handle"></div><div class="sheet-header">${button('close-menu','Закрыть','close')}<h4>${escapeText(title)}</h4><span style="width:44px"></span></div><div class="menu-content">${contents}</div></section>`;
s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true);
s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true});
}
const menuAction=(action,label,i='file')=>`<button class="menu-action" data-action="${action}">${icon(i)}${label}</button>`;
function updateEditor(s) {
const modal=s.phone.querySelector('.overlay');
modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`;
modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor];
modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s);
}
function handle(s, event) {
const el=event.target.closest('[data-action]');
if (!el || el.disabled) return;
const action=el.dataset.action;
if (action==='filter') { s.filter=el.dataset.value; render(s); }
else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); }
else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); }
else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); }
else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); }
else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el);
else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); }
else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); }
else if (action==='attach') s.phone.querySelector('[data-file]').click();
else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); }
else if (action==='send') {
const mode=s.editor;
const text=s.drafts[mode].trim();
const attachment=s.attachments[mode];
if (!text&&!attachment) return;
if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost});
else s.posts.push(text+(attachment?'\n📎 '+attachment:''));
s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s);
const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight;
s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.');
}
else if (action==='like') {
const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key);
el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1));
}
else if (action==='share') menu(s,'Поделиться', '<p>В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.</p>'+menuAction('close-menu','Понятно','check'),el);
else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; }
else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); }
else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'<p>Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.</p>',el);
else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el);
else if (action==='contents') menu(s,'Оглавление','<p>С чего начать знакомство с каналом</p>'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el);
else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); }
else if (action==='reactions') menu(s,'Лайки','<p>Все: 24 · основные: 18 · сияющие: 6</p><p>Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.</p>',el);
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
}
function attachEvents(s) {
s.phone.addEventListener('click',event=>handle(s,event));
s.phone.addEventListener('input',event=>{
if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); }
if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); }
});
s.phone.addEventListener('change',event=>{
if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); }
});
s.phone.addEventListener('keydown',event=>{
const dialog=s.phone.querySelector('[role="dialog"]');
if(!dialog) return;
if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
if(event.key==='Tab') {
const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')];
const first=focusable[0], last=focusable.at(-1);
if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); }
else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); }
}
});
}
function init() {
const query=new URLSearchParams(location.search);
const chosen=concepts.find(c=>c.id===query.get('variant'));
const gallery=document.getElementById('gallery');
if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');}
for(const c of chosen?[chosen]:concepts) {
const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title);
article.innerHTML=`<div class="concept-heading"><span class="concept-number">${c.number}</span><h2>${c.title}</h2><a class="open-concept" href="?variant=${c.id}" aria-label="Открыть отдельно: ${c.title}">↗</a></div><p class="concept-description">${c.description}</p><div class="phone" data-theme="${c.id}" aria-label="Макет: ${c.title}"></div><div class="concept-notes"><div class="swatches" aria-hidden="true">${c.colors.map(color=>`<span style="background:${color}"></span>`).join('')}</div><span class="concept-tag">${c.tag}</span><div>${c.note}</div></div>`;
gallery.append(article);
const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null};
states.set(c.id,s);render(s);attachEvents(s);
}
document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{
document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));});
for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); }
el.focus({preventScroll:true});
}));
document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);});
document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});});
}
init();
+314
View File
@@ -0,0 +1,314 @@
// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs
// Зависимости проверки (не приложения): jsdom, postcss.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs'));
const { JSDOM } = require('jsdom');
const postcss = require('postcss');
const ui = path.dirname(fileURLToPath(import.meta.url));
const dom = new JSDOM('<button id="opener">Ответить</button><main id="app-screen"></main><div id="modal-root"></div>', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' });
const context = dom.getInternalVMContext();
const { window } = dom;
const { document } = window;
const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} };
let placed = 0;
let authPrompts = 0;
const mocks = {
'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) },
'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text },
'state.js': { state },
'ui-error-texts.js': { toUserMessage: (error) => error.message },
'avatar-image.js': { renderUserAvatar: () => document.createElement('span') },
'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ },
};
async function moduleAt(relative, mockImports = false) {
const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context });
await module.link(async (specifier) => {
const values = mockImports && mocks[path.basename(specifier)];
assert.ok(values, `Неожиданный импорт ${specifier}`);
return new vm.SyntheticModule(Object.keys(values), function () {
for (const [key, value] of Object.entries(values)) this.setExport(key, value);
}, { context });
});
await module.evaluate();
return module.namespace;
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 35));
const query = (selector) => document.querySelector(selector);
function input(value) {
const field = query('textarea');
field.value = value;
field.dispatchEvent(new window.Event('input', { bubbles: true }));
}
function key(key, options = {}) {
const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options });
document.activeElement.dispatchEvent(event);
return event;
}
const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true);
let sent = [];
const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) };
query('#opener').focus();
let editor = openChannelEditor(options);
await tick();
assert.equal(document.activeElement.tagName, 'TEXTAREA');
assert.equal(query('.channel-editor__submit').disabled, true);
input('Первая строка\nВторая строка');
assert.equal(key('Enter').defaultPrevented, false);
assert.equal(sent.length, 0);
editor.close();
await tick();
assert.equal(document.activeElement.id, 'opener');
editor = openChannelEditor(options);
await tick();
assert.equal(query('textarea').value, 'Первая строка\nВторая строка');
key('Enter', { ctrlKey: true, isComposing: true });
assert.equal(sent.length, 0);
key('Enter', { ctrlKey: true });
await tick();
assert.equal(sent.length, 1);
assert.equal(query('.channel-editor-overlay'), null);
editor = openChannelEditor(options);
assert.equal(query('textarea').value, '');
editor.close();
await tick();
editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } });
await tick();
input('Не потерять');
query('.channel-editor__submit').click();
await tick();
assert.equal(query('[role="alert"]').textContent, 'Нет соединения');
assert.equal(query('textarea').value, 'Не потерять');
assert.equal(query('.channel-editor__submit').disabled, false);
query('.channel-editor__close').focus();
key('Tab', { shiftKey: true });
assert.equal(document.activeElement, query('.channel-editor__submit'));
editor.close();
await tick();
state.session.login = 'bob';
editor = openChannelEditor(options);
assert.equal(query('textarea').value, '');
editor.close();
await tick();
state.session.login = 'alice';
editor = openChannelEditor(options);
assert.equal(query('textarea').value, 'Не потерять');
query('.channel-editor__clear').click();
assert.equal(query('textarea').value, '');
query('.channel-editor__attach').click();
await tick();
assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1);
assert.equal(query('.channel-editor__submit').disabled, false);
query('.channel-editor__submit').click();
await tick();
assert.equal(placed, 1);
editor = openChannelEditor(options);
await tick();
window.history.back();
await tick();
assert.equal(query('.channel-editor-overlay'), null);
editor.close();
state.session.isAuthorized = false;
assert.equal(openChannelEditor(options), null);
assert.equal(authPrompts, 1);
const theme = await moduleAt('js/services/theme-service.js');
assert.equal(theme.applyThemeMode('system').resolved, 'dark');
theme.setThemeMode('light');
assert.equal(document.documentElement.dataset.theme, 'light');
theme.setThemeMode('dark');
assert.equal(document.documentElement.dataset.theme, 'dark');
const scroll = await moduleAt('js/services/channel-view-state.js');
query('#app-screen').scrollTop = 123;
scroll.rememberChannelPosition('alice:channel:one');
query('#app-screen').scrollTop = 0;
scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one'));
assert.equal(query('#app-screen').scrollTop, 123);
assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined);
const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js');
const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] });
menu.open();
assert.equal(document.activeElement.textContent, 'Первый');
key('ArrowDown');
assert.equal(document.activeElement.textContent, 'Второй');
key('Escape');
assert.equal(document.activeElement.id, 'opener');
menu.destroy();
menu.destroy();
assert.equal(query('.dropdown-portal'), null);
console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.');
// DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн.
let replyOptions;
const reactionState = new Map();
const iconModule = await moduleAt('js/components/ui-icon.js');
const pageValues = {
state, authService: {},
channels: [],
readChannelNotificationsState: () => ({}),
createSkeletonCard: () => document.createElement('div'),
createTopBar: ({ center }) => {
const header = document.createElement('header');
if (center) header.append(center);
return header;
},
iconHtml: iconModule.iconHtml,
parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }),
parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }),
loadProfileSnapshot: async () => null,
renderAvatar: () => document.createElement('span'),
renderUserAvatar: () => document.createElement('span'),
formatRelativeTime: () => 'сейчас',
escapeHtml: (text) => String(text || ''),
openChannelEditor: (options) => { replyOptions = options; },
getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked',
setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value),
makeShineMessageRoute: () => 'thread:test',
attachMessageMenu: (card, head, items) => {
const button = document.createElement('button');
head.append(button);
card.testMenu = items;
const menu = createDropdownMenu({ anchorEl: button, items });
card.cleanup = () => menu.destroy();
},
};
async function pageProducer(file, exported) {
const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8');
const imports = new Map();
for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]);
}
const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context });
await module.link(async (specifier) => {
const names = imports.get(specifier);
assert.ok(names, specifier);
return new vm.SyntheticModule(names, function () {
names.forEach((name) => this.setExport(name, pageValues[name] || (() => {})));
}, { context });
});
await module.evaluate();
return module.namespace[exported];
}
state.session.isAuthorized = true;
state.session.login = 'alice';
const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) };
let likes = 0;
let navigations = 0;
const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true };
const renderPost = await pageProducer('channel-view.js', 'renderPostCard');
const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers);
document.body.append(post);
assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация');
assert.equal(post.querySelectorAll('.channel-action-counter').length, 3);
assert.ok(post.testMenu.some((item) => item.label === 'Редактировать'));
assert.ok(post.testMenu.some((item) => item.label === 'Удалить'));
post.querySelector('.channel-action-like').click();
await tick();
assert.equal(likes, 1);
assert.equal(post.querySelector('.channel-action-like').disabled, false);
post.querySelector('.channel-action-reply').click();
assert.equal(replyOptions.context.author, 'alice');
assert.equal(replyOptions.context.text, 'Публикация');
assert.equal(navigations, 0);
post.cleanup(); post.cleanup(); post.remove();
const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard');
const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2);
document.body.append(node);
assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ');
assert.ok(!node.testMenu.some((item) => item.label === 'Удалить'));
node.querySelector('.thread-reply-btn').click();
assert.equal(replyOptions.context.author, 'bob');
node.cleanup(); node.cleanup(); node.remove();
console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.');
pageValues.authService.onEvent = () => () => {};
pageValues.toUserMessage = (error) => error.message;
pageValues.rememberChannelPosition = scroll.rememberChannelPosition;
pageValues.readChannelPosition = scroll.readChannelPosition;
pageValues.restoreChannelPosition = scroll.restoreChannelPosition;
state.channelsFeed = {};
state.channelIndex = {};
const chrome = { setTopbar() {}, setComposer() {} };
for (const [file, method, params, payload] of [
['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }],
['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }],
['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }],
]) {
let finish;
pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; });
const render = await pageProducer(file, 'render');
const screen = render({ route: { params }, navigate() {}, chrome });
query('#app-screen').append(screen);
await tick();
assert.ok(finish, `${file}: запрос начат`);
screen.cleanup();
screen.cleanup();
const markup = screen.innerHTML;
finish(payload);
await tick();
assert.equal(screen.innerHTML, markup, `${file}: async после dispose`);
assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`);
screen.remove();
pageValues.authService[method] = async () => payload;
const loaded = render({ route: { params }, navigate() {}, chrome });
query('#app-screen').append(loaded);
await tick();
const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block';
assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`);
if (loaded.refresh) await loaded.refresh();
assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`);
loaded.cleanup(); loaded.remove();
}
console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.');
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]);
}
const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js'));
for (const file of scripts) {
const source = fs.readFileSync(file, 'utf8');
for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) {
assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`);
}
}
const repo = path.dirname(ui);
const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n');
let cssCount = 0;
for (const relative of changed) {
const file = path.join(repo, relative);
if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) });
if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue;
cssCount++;
const source = fs.readFileSync(file, 'utf8');
const tree = postcss.parse(source, { from: file });
let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
const baselineRules = new Map();
postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1));
const rules = new Map();
const duplicates = [];
tree.walkRules((rule) => {
const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params);
const key = parents.join('/') + ':' + rule.selector;
const count = (rules.get(key) || 0) + 1;
rules.set(key, count);
if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key);
});
assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`);
assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`);
}
const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8');
const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]);
assert.equal(new Set(cssManifest).size, cssManifest.length);
for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`);
assert.ok(cssManifest.includes('styles/components/channel-editor.css'));
console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`);
dom.window.close();
+17 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
/>
<base href="/" />
<link rel="manifest" href="./manifest.webmanifest" />
@@ -12,13 +12,28 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
(function applySavedThemeBeforePaint() {
let mode = 'system';
try {
const saved = localStorage.getItem('shine-ui-theme-mode-v1');
if (saved === 'light' || saved === 'dark' || saved === 'system') mode = saved;
} catch {}
const resolved = mode === 'system'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: mode;
document.documentElement.dataset.themeMode = mode;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
}());
</script>
<script>
window.__SHINE_BUILD_HASH__ = '20260901134200';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
(function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
+5
View File
@@ -83,6 +83,7 @@ import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
@@ -404,6 +405,7 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
}
const MANAGED_SHELL_CLASSES = [
'app-shell--wide',
'app-shell--top-fade',
'app-shell--bottom-fade',
'app-shell--bottom-fade-composer',
@@ -455,6 +457,7 @@ function normalizeShellMode(mode = {}, showAppChrome = true) {
function applyShellMode(mode, showAppChrome = true) {
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
const normalized = normalizeShellMode(mode, showAppChrome);
appShellEl.classList.toggle('app-shell--wide', normalized.contentWidth === 'wide');
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
@@ -1532,6 +1535,8 @@ async function ensureSessionRuntimeStarted() {
}
async function init() {
applyThemeMode();
watchSystemTheme();
consumeCallPushActionFromUrlIfAny();
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
void tryLockPortraitOrientation();
@@ -300,6 +300,7 @@ export function openArweaveAttachmentManager({
autoOpenFileDialog = true,
shineType = '',
extraUploadTags = [],
signal = null,
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -359,6 +360,7 @@ export function openArweaveAttachmentManager({
}
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
if (closed) return;
const item = persistToHistory
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement,
@@ -373,6 +375,9 @@ export function openArweaveAttachmentManager({
}
return new Promise((resolve) => {
const onAbort = () => close(resolve, null);
if (signal?.aborted) { onAbort(); return; }
signal?.addEventListener('abort', onAbort, { once: true });
const bindBackdrop = () => {
const modal = root.querySelector('[data-ar-attach-modal="true"]');
modal?.addEventListener('click', (event) => {
+363
View File
@@ -0,0 +1,363 @@
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
import { state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { renderUserAvatar } from './avatar-image.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const drafts = new Map();
function draftKey(key) {
const login = String(state.session.login || 'guest').trim().toLowerCase();
return `${login}:${String(key || 'editor')}`;
}
function attachmentLabel(item) {
const name = String(item?.name || 'Файл');
const size = Number(item?.size || 0);
if (!size) return name;
if (size < 1024) return `${name} · ${size} Б`;
if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`;
return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`;
}
export function openChannelEditor({
id = 'channel-editor',
title = 'Ответ',
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
key = 'reply',
extraControl = null,
initialText = '',
allowEmptyText = false,
allowAttachments = true,
rawText = false,
onSubmit,
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
return null;
}
const root = document.getElementById('modal-root');
if (!root || typeof onSubmit !== 'function') return null;
if (root.querySelector('.channel-editor-overlay')) return null;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const storageKey = draftKey(key);
const login = state.session.login;
const saved = drafts.get(storageKey) || { text: initialText, attachments: [] };
const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : [];
const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : [];
extraFields.forEach((field, index) => {
if (saved.controls?.[index] !== undefined) field.value = saved.controls[index];
});
let inFlight = false;
let composing = false;
let closed = false;
let picking = false;
let completed = false;
const attachmentController = new AbortController();
const overlay = document.createElement('div');
overlay.className = 'channel-editor-overlay';
overlay.id = id;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', `${id}-title`);
const dialog = document.createElement('div');
dialog.className = 'channel-editor';
const header = document.createElement('header');
header.className = 'channel-editor__header';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.textContent = '×';
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
header.append(closeButton, heading, document.createElement('span'));
const body = document.createElement('div');
body.className = 'channel-editor__body';
if (context && (context.author || context.text || context.attachmentLabel)) {
const contextBox = document.createElement('div');
contextBox.className = 'channel-editor__context';
const contextTitle = document.createElement('strong');
contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения';
const quote = document.createElement('p');
quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста');
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'text-btn channel-editor__context-toggle';
expand.textContent = 'Показать целиком';
expand.setAttribute('aria-expanded', 'false');
expand.addEventListener('click', () => {
const expanded = contextBox.classList.toggle('is-expanded');
expand.textContent = expanded ? 'Свернуть' : 'Показать целиком';
expand.setAttribute('aria-expanded', String(expanded));
});
contextBox.append(contextTitle, quote, expand);
body.append(contextBox);
}
const textarea = document.createElement('textarea');
textarea.className = 'channel-editor__input';
textarea.maxLength = 2000;
textarea.placeholder = placeholder;
textarea.setAttribute('aria-label', placeholder);
textarea.value = String(saved.text || '');
const attachmentsBox = document.createElement('div');
attachmentsBox.className = 'channel-editor__attachments';
attachmentsBox.setAttribute('aria-live', 'polite');
const error = document.createElement('p');
error.className = 'channel-editor__error';
error.setAttribute('role', 'alert');
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'text-btn channel-editor__clear';
clearButton.textContent = 'Очистить черновик';
const author = document.createElement('div');
author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость';
author.append(authorName);
body.append(author);
if (extraControl instanceof Node) body.append(extraControl);
body.append(textarea, attachmentsBox, error, clearButton);
const footer = document.createElement('footer');
footer.className = 'channel-editor__footer';
const attachButton = document.createElement('button');
attachButton.type = 'button';
attachButton.className = 'secondary-btn channel-editor__attach';
attachButton.textContent = 'Прикрепить';
attachButton.hidden = !allowAttachments;
const counter = document.createElement('span');
counter.className = 'channel-editor__counter';
const submitButton = document.createElement('button');
submitButton.type = 'button';
submitButton.className = 'primary-btn channel-editor__submit';
submitButton.textContent = submitLabel;
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
footer.append(attachButton);
footer.append(counter, submitButton);
dialog.append(header, body, footer);
overlay.append(dialog);
root.replaceChildren(overlay);
const saveDraft = () => {
const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) };
if (completed) { drafts.delete(storageKey); return; }
if (value.text || value.attachments.length) drafts.set(storageKey, value);
else drafts.delete(storageKey);
};
const sync = () => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`;
const length = textarea.value.length;
counter.textContent = `${length} / 2000`;
counter.classList.toggle('is-near-limit', length >= 1800);
submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0);
dialog.setAttribute('aria-busy', String(inFlight || picking));
clearButton.hidden = !textarea.value && attachments.length === 0;
attachmentsBox.replaceChildren();
attachments.forEach((item, index) => {
const card = document.createElement('div');
card.className = 'channel-editor__attachment';
const label = document.createElement('span');
label.textContent = attachmentLabel(item);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`);
remove.textContent = '×';
remove.disabled = inFlight;
remove.addEventListener('click', () => {
attachments.splice(index, 1);
saveDraft();
sync();
});
card.append(label, remove);
attachmentsBox.append(card);
});
};
const openedUrl = location.href;
const historyId = `${Date.now()}:${Math.random()}`;
history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl);
const viewport = window.visualViewport;
const updateViewport = () => {
overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`);
overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`);
};
const onBack = (event) => {
if (closed) return;
event.stopImmediatePropagation();
close({ fromHistory: true });
};
const observer = new MutationObserver(() => {
if (!overlay.isConnected) close({ restoreFocus: false });
});
const close = ({ fromHistory = false, restoreFocus = true } = {}) => {
if (closed) return;
closed = true;
saveDraft();
observer.disconnect();
attachmentController.abort();
document.removeEventListener('keydown', onDocumentKeydown);
viewport?.removeEventListener('resize', updateViewport);
viewport?.removeEventListener('scroll', updateViewport);
window.removeEventListener('resize', updateViewport);
window.removeEventListener('popstate', onBack, true);
overlay.remove();
if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) {
window.addEventListener('popstate', (event) => {
if (location.href === openedUrl) event.stopImmediatePropagation();
}, { capture: true, once: true });
history.back();
}
if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true });
};
overlay.cleanup = () => close({ restoreFocus: false });
const submit = async () => {
if (inFlight || submitButton.disabled) return;
inFlight = true;
error.textContent = '';
textarea.disabled = true;
attachButton.disabled = true;
clearButton.disabled = true;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; });
submitButton.textContent = 'Отправляем…';
sync();
try {
await onSubmit({
text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments),
attachments: [...attachments],
});
completed = true;
attachments.forEach((item) => markArweaveAttachmentPlaced(login, item));
// Не удаляем новый черновик, открытый после закрытия отправляющего редактора.
if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey);
if (!isActive() || closed) return;
close();
} catch (submitError) {
if (!isActive() || closed) return;
inFlight = false;
textarea.disabled = false;
attachButton.disabled = false;
clearButton.disabled = false;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; });
submitButton.textContent = submitLabel;
error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.');
saveDraft();
sync();
}
};
const onDocumentKeydown = (event) => {
if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) {
event.preventDefault();
void submit();
return;
}
if (event.key !== 'Tab') return;
const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
closeButton.addEventListener('click', () => close());
clearButton.addEventListener('click', () => {
textarea.value = '';
attachments.splice(0);
drafts.delete(storageKey);
error.textContent = '';
sync();
textarea.focus();
});
textarea.addEventListener('input', () => {
error.textContent = '';
saveDraft();
sync();
});
textarea.addEventListener('compositionstart', () => { composing = true; });
extraFields.forEach((field) => field.addEventListener('change', saveDraft));
textarea.addEventListener('compositionend', () => { composing = false; });
attachButton.addEventListener('click', async () => {
if (picking || inFlight) return;
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
try {
picking = true;
dialog.inert = true;
sync();
const item = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
signal: attachmentController.signal,
});
if (!isActive() || !overlay.isConnected || !item) return;
attachments.push(item);
saveDraft();
sync();
} catch (attachError) {
if (isActive() && overlay.isConnected) {
error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.');
}
} finally {
picking = false;
dialog.inert = false;
if (!closed) { sync(); attachButton.focus(); }
}
});
submitButton.addEventListener('click', () => void submit());
document.addEventListener('keydown', onDocumentKeydown);
window.addEventListener('popstate', onBack, true);
viewport?.addEventListener('resize', updateViewport);
viewport?.addEventListener('scroll', updateViewport);
window.addEventListener('resize', updateViewport);
observer.observe(root, { childList: true });
updateViewport();
sync();
requestAnimationFrame(() => {
if (closed || !isActive()) return;
const quote = overlay.querySelector('.channel-editor__context p');
const expand = overlay.querySelector('.channel-editor__context-toggle');
if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight;
sync();
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
});
return { close };
}
+15 -3
View File
@@ -110,7 +110,7 @@ export function createDropdownMenu({
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
close();
close({ focusAnchor: true });
await item.action?.();
});
menuEl.append(button);
@@ -154,6 +154,7 @@ export function createDropdownMenu({
setAnchorOpen(true);
onOpen?.();
position();
menuEl.querySelector('button:not(:disabled)')?.focus();
};
const toggle = () => {
@@ -172,8 +173,19 @@ export function createDropdownMenu({
close();
};
const onKeydown = (event) => {
if (event.key !== 'Escape' || !portal) return;
close({ focusAnchor: true });
if (!portal) return;
if (event.key === 'Escape' || event.key === 'Tab') {
if (event.key === 'Escape') event.preventDefault();
close({ focusAnchor: true });
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const buttons = [...menuEl.querySelectorAll('button:not(:disabled)')];
if (!buttons.length) return;
event.preventDefault();
const index = buttons.indexOf(document.activeElement);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length;
buttons[next].focus();
};
const onNavigation = () => close();
const onViewportChange = () => position();
+12
View File
@@ -0,0 +1,12 @@
import { createDropdownMenu } from './dropdown-menu.js';
export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more';
trigger.textContent = '⋯';
trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger);
const menu = createDropdownMenu({ anchorEl: trigger, items });
card.cleanup = () => menu.destroy();
}
+10 -13
View File
@@ -1,23 +1,18 @@
import { resolveToolbarActive } from '../router.js';
import { state, authService } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю).
import { iconHtml as lineIcon } from './ui-icon.js';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'messages-list', label: 'Личные' },
{ pageId: 'channels-list', label: 'Каналы' },
{ pageId: 'network-view', label: 'Связи' },
{ pageId: 'notifications-view', label: 'Уведомления' },
{ pageId: 'profile-view', label: 'Профиль' },
];
function iconHtml(item) {
return item.iconImg
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />`
: `<span>${item.icon}</span>`;
const names = { 'messages-list': 'message', 'channels-list': 'channels', 'network-view': 'network', 'notifications-view': 'bell', 'profile-view': 'profile' };
return lineIcon(names[item.pageId]);
}
function normalizeCounters(payload = {}) {
@@ -126,6 +121,8 @@ export function renderToolbar(currentPageId, navigate) {
const isMessages = item.pageId === 'messages-list';
const isNetwork = item.pageId === 'network-view';
const isNotifications = item.pageId === 'notifications-view';
btn.type = 'button';
if (item.pageId === active) btn.setAttribute('aria-current', 'page');
btn.dataset.toolbarPage = item.pageId;
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
if (isProfile) {
+14
View File
@@ -0,0 +1,14 @@
const paths = {
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
};
export function iconHtml(name, filled = false) {
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
}
+110 -201
View File
@@ -1,3 +1,6 @@
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js';
@@ -12,18 +15,17 @@ import {
} from '../services/channels-ux.js';
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { openChannelEditor } from '../components/channel-editor.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -63,7 +65,7 @@ function createThreadAvatar(login) {
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'sm',
className: 'channel-message-avatar',
className: 'channel-message-avatar avatar-plain',
title,
});
if (!cleanLogin) return avatarEl;
@@ -78,7 +80,7 @@ function createThreadAvatar(login) {
}
: null,
size: 'sm',
className: 'channel-message-avatar',
className: 'channel-message-avatar avatar-plain',
title,
});
avatarEl.replaceWith(upgraded);
@@ -490,120 +492,18 @@ function resolveNodeText(node) {
);
}
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'thread-reply' }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
const emptyError = isRating
? 'Введите текст оценки или добавьте вложение.'
: 'Введите текст ответа или добавьте вложение.';
const submitError = isRating
? 'Не удалось отправить оценку.'
: 'Не удалось отправить ответ.';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
return openChannelEditor({
id: 'thread-reply-modal',
title: isRating ? 'Оценка' : 'Ответ',
submitLabel: isRating ? 'Отправить' : 'Ответить',
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
context,
key: `${draftKey}:${mode}`,
isActive,
onSubmit: ({ text }) => onSubmit(text),
});
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
if (textEl) textEl.focus();
}
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
@@ -722,58 +622,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
<div class="modal-card stack">
<h3 class="modal-title">Редактировать сообщение</h3>
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
<div class="meta-muted inline-error" id="thread-edit-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
</div>
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
</div>
</div>
`;
const textEl = root.querySelector('#thread-edit-text');
const errorEl = root.querySelector('#thread-edit-error');
if (textEl) textEl.value = String(initialText || '');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
return openChannelEditor({
id: 'thread-edit-modal', title: 'Редактирование', submitLabel: 'Сохранить',
placeholder: 'Текст сообщения', initialText, allowEmptyText,
key: draftKey, allowAttachments: false, rawText: true, isActive,
onSubmit: ({ text }) => onSave(text),
});
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
if (textEl) textEl.focus();
}
function renderNodeCard(node, heading, handlers, localNumber) {
@@ -789,7 +644,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const shiningLikes = Number(node?.shiningLikesCount || 0);
const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
const isOwnMessage = Boolean(state.session.isAuthorized && state.session.login) && String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase();
const msgSubType = Number(node?.msgSubType || 0);
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
@@ -809,6 +664,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'ui-button channel-message-author-tile';
const menuItems = [];
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
@@ -829,7 +685,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
titleMain.append(loginEl, numberEl);
title.append(titleMain);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
const editedMarker = document.createElement('span');
editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
@@ -928,21 +784,17 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.className = 'ui-button channel-action-item thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
<span class="channel-action-counter">${likes}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
setActionTitle(likeButton, 'Лайк...');
try {
@@ -954,6 +806,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
targetBlockNumber: target?.blockNumber,
});
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
} finally {
if (likeButton.isConnected) likeButton.disabled = false;
}
});
@@ -961,7 +815,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
@@ -970,9 +824,14 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue),
isActive: handlers.isActive,
draftKey: `message:${refKey}`,
context: {
author,
text: parsedText.text,
attachmentLabel: parsedText.attachments[0]?.name || '',
},
});
});
// Rating/opinion action is intentionally hidden from UI for now.
@@ -982,7 +841,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
@@ -994,7 +853,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton);
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item';
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
actions.append(likeButton, discussionButton, shareButton, replyButton);
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
@@ -1016,7 +881,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
messageBlockNumber: repostTarget.blockNumber,
}));
});
actions.append(originalButton);
menuItems.push({ label: 'Оригинал', action: () => originalButton.click() });
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
@@ -1038,7 +903,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
msgSubType,
}), { isActive: handlers.isActive });
});
actions.append(detailsButton);
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -1052,14 +917,23 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
isActive: handlers.isActive,
draftKey: `edit:${messageRefKey(target)}`,
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
});
});
actions.append(editButton);
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
}
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: versions }) });
attachMessageMenu(card, headRow, menuItems);
card.append(actions);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
@@ -1067,13 +941,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (!login) return;
handlers.navigate(makeProfileRoute(login));
});
card.addEventListener('click', () => {
handlers.onOpenThread(target);
});
return card;
}
function renderDescendants(items, handlers, nextNumber, depth = 0) {
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
const wrap = document.createElement('div');
wrap.className = 'stack';
@@ -1083,11 +954,17 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
const nodeNumber = nextNumber();
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4)));
if (parent) {
const context = document.createElement('p');
context.className = 'thread-reply-context';
const excerpt = parseMessageAttachments(resolveNodeText(parent)).text;
context.textContent = `В ответ ${parent.authorLogin || 'автору'} · ${excerpt.slice(0, 100) || 'Вложение'}`;
row.prepend(context);
}
wrap.append(row);
if (Array.isArray(branch?.children) && branch.children.length) {
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1, branch.node));
}
} catch (error) {
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
@@ -1139,6 +1016,7 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
const positionKey = `${state.session.login}:thread:${routeKey}`;
let disposed = false;
let refreshSeq = 0;
@@ -1348,6 +1226,8 @@ export function render({ navigate, route, chrome }) {
screen.append(statusBox);
const clearContent = () => {
chrome?.setComposer(null);
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
for (const timerId of refreshTimers) window.clearTimeout(timerId);
refreshTimers.clear();
Array.from(screen.children).forEach((child) => {
@@ -1365,6 +1245,7 @@ export function render({ navigate, route, chrome }) {
'#thread-reply-modal',
'#thread-repost-modal',
].join(','))) {
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
modalRoot.innerHTML = '';
}
};
@@ -1377,7 +1258,9 @@ export function render({ navigate, route, chrome }) {
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
const hadContent = !!screen.querySelector('.thread-block');
const restorePosition = hadContent ? document.getElementById('app-screen')?.scrollTop : readChannelPosition(positionKey);
if (!hadContent) clearContent();
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
@@ -1393,7 +1276,7 @@ export function render({ navigate, route, chrome }) {
return;
}
const skeleton = renderSkeleton(screen);
const skeleton = hadContent ? null : renderSkeleton(screen);
try {
let resolvedMessage = selector.message;
@@ -1468,7 +1351,7 @@ export function render({ navigate, route, chrome }) {
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
skeleton?.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
const focus = payload?.focus || null;
@@ -1499,7 +1382,7 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
event.preventDefault();
@@ -1510,6 +1393,7 @@ export function render({ navigate, route, chrome }) {
};
}
clearContent();
let localSeq = 0;
const nextNumber = () => {
localSeq += 1;
@@ -1532,16 +1416,32 @@ export function render({ navigate, route, chrome }) {
focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title';
focusTitle.textContent = 'Текущее сообщение';
focusWrap.append(focusTitle);
focusTitle.textContent = 'Исходное сообщение';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
const composer = document.createElement('div');
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'primary-btn';
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
isActive: () => !disposed,
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
});
});
composer.append(reply);
chrome?.setComposer(composer);
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы и оценки';
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
@@ -1549,7 +1449,7 @@ export function render({ navigate, route, chrome }) {
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ответов и оценок пока нет.';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
}
@@ -1565,7 +1465,8 @@ export function render({ navigate, route, chrome }) {
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
if (!hasPendingScroll && Number.isFinite(restorePosition)) restoreChannelPosition(restorePosition);
if (!hasPendingScroll && !Number.isFinite(restorePosition) && focusWrap) {
trackTimer(window.setTimeout(() => {
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
@@ -1573,10 +1474,17 @@ export function render({ navigate, route, chrome }) {
}
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
skeleton?.remove();
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', () => void refresh());
failed.append(retry);
screen.append(failed);
}
};
@@ -1584,6 +1492,7 @@ export function render({ navigate, route, chrome }) {
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
rememberChannelPosition(positionKey);
disposed = true;
refreshSeq += 1;
clearContent();
+153 -279
View File
@@ -1,3 +1,6 @@
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
@@ -17,6 +20,7 @@ import {
softHaptic,
} from '../services/channels-ux.js';
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { openChannelEditor } from '../components/channel-editor.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
@@ -35,7 +39,7 @@ import {
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -88,7 +92,7 @@ function createMessageAvatar(login) {
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'sm',
className: 'channel-message-avatar',
className: 'channel-message-avatar avatar-plain',
title,
});
if (!cleanLogin) return avatarEl;
@@ -103,7 +107,7 @@ function createMessageAvatar(login) {
}
: null,
size: 'sm',
className: 'channel-message-avatar',
className: 'channel-message-avatar avatar-plain',
title,
});
avatarEl.replaceWith(upgraded);
@@ -769,7 +773,7 @@ function bindSubmitOnPlainEnter(textarea, submit) {
if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return;
textarea.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.ctrlKey) return;
if (!(event.ctrlKey || event.metaKey) || event.isComposing) return;
event.preventDefault();
submit();
});
@@ -880,101 +884,18 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'reply' }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
const emptyError = isRating
? 'Введите текст оценки или добавьте вложение.'
: 'Введите текст ответа или добавьте вложение.';
const submitError = isRating
? 'Не удалось отправить оценку.'
: 'Не удалось отправить ответ.';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#reply-text');
const attachmentsEl = root.querySelector('#reply-attachments');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#reply-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
return openChannelEditor({
id: 'reply-modal',
title: isRating ? 'Оценка' : 'Ответ',
submitLabel: isRating ? 'Отправить' : 'Ответить',
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
context,
key: `${draftKey}:${mode}`,
isActive,
onSubmit: ({ text }) => onSubmit(text),
});
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
@@ -1246,104 +1167,30 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
}
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
<div class="modal-card stack">
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="draft-attachments" id="channel-message-attachments"></div>
<div class="channel-message-tools">
<select id="channel-message-type" class="input channel-message-type-select">
<option value="${10}">Пост</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
</select>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<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 typeWrap = document.createElement('label');
typeWrap.className = 'channel-editor__type';
typeWrap.textContent = 'Тип сообщения';
const typeSelect = document.createElement('select');
typeSelect.className = 'select';
typeSelect.innerHTML = `
<option value="10">Публикация</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
`;
typeWrap.append(typeSelect);
const textEl = root.querySelector('#channel-message-text');
const typeEl = root.querySelector('#channel-message-type');
const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
if (typeEl) typeEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
const msgSubType = Number(typeEl?.value || 10);
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
return openChannelEditor({
id: 'channel-message-modal',
title: 'Новое сообщение',
submitLabel: 'Опубликовать',
placeholder: 'Напишите сообщение',
key: `channel-post:${channelName}`,
extraControl: typeWrap,
isActive,
onSubmit: ({ text }) => onSubmit({
text,
msgSubType: Number(typeSelect.value || 10),
}),
});
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
@@ -1382,59 +1229,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
<div class="modal-card stack">
<h3 class="modal-title">Редактировать сообщение</h3>
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
<div class="meta-muted inline-error" id="edit-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
</div>
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
</div>
</div>
`;
const textEl = root.querySelector('#edit-message-text');
const errorEl = root.querySelector('#edit-message-error');
if (textEl) textEl.value = String(initialText || '');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
return openChannelEditor({
id: 'edit-message-modal', title: 'Редактирование', submitLabel: 'Сохранить',
placeholder: 'Текст сообщения', initialText, allowEmptyText,
key: draftKey, allowAttachments: false, rawText: true, isActive,
onSubmit: ({ text }) => onSave(text),
});
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
if (textEl) textEl.focus();
}
function mapApiMessageToPost(message, selector, localNumber) {
@@ -1488,7 +1289,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
isOwnMessage: Boolean(state.session.isAuthorized && state.session.login) && String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase(),
};
}
@@ -2034,6 +1835,7 @@ function renderPostCard(post, {
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const menuItems = [];
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
@@ -2055,7 +1857,7 @@ function renderPostCard(post, {
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
const editedMarker = document.createElement('span');
editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
@@ -2186,24 +1988,47 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
likeButton.disabled = isPending;
likeButton.addEventListener('click', (event) => {
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike });
likeButton.disabled = true;
try {
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
} catch (error) {
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
} finally {
if (likeButton.isConnected) likeButton.disabled = false;
}
});
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item channel-action-discussion';
discussionButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
discussionButton.addEventListener('click', (event) => {
event.stopPropagation();
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
@@ -2212,21 +2037,26 @@ function renderPostCard(post, {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
isActive,
draftKey: `message:${refKey}`,
context: {
author: post.authorLogin,
text: parsedBody.text,
attachmentLabel: parsedBody.attachments[0]?.name || '',
},
});
});
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, replyButton);
actions.append(likeButton, discussionButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
@@ -2237,7 +2067,7 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(shareButton);
actions.append(shareButton, replyButton);
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
@@ -2259,7 +2089,7 @@ function renderPostCard(post, {
messageBlockNumber: post.targetRef.blockNumber,
}));
});
actions.append(originalBtn);
menuItems.push({ label: 'Оригинал', action: () => originalBtn.click() });
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
@@ -2281,7 +2111,7 @@ function renderPostCard(post, {
msgSubType: post.msgSubType,
}), { isActive });
});
actions.append(detailsButton);
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -2295,19 +2125,24 @@ function renderPostCard(post, {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
isActive,
draftKey: `edit:${messageRefKey(post.messageRef)}`,
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
allowEmptyText: parsedBody.attachments.length > 0,
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
actions.append(editButton);
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
try { await onEdit(post.messageRef, '', { isDelete: true }); }
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
}
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
card.append(actions);
card.addEventListener('click', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
return card;
}
@@ -2325,8 +2160,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
actionButton.type = 'button';
actionButton.className = 'primary-btn channel-main-action';
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
const addMessageButton = document.createElement('button');
addMessageButton.type = 'button';
@@ -2416,16 +2252,22 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (channelData.isDiary) {
screen.append(feed, backButton);
} else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton);
screen.append(feed);
const composer = document.createElement('div');
composer.className = 'channel-composer';
composer.append(addMessageButton);
handlers.chrome?.setComposer(composer);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(feed, actionButton);
screen.append(actionButton, feed);
} else {
screen.append(feed);
}
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
const restorePosition = handlers.restorePosition;
const pendingScrollTimer = Number.isFinite(restorePosition) && !hasPendingScrollTarget ? 0 : applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (Number.isFinite(restorePosition) && !hasPendingScrollTarget) restoreChannelPosition(restorePosition);
const unreadScrollTimer = !Number.isFinite(restorePosition) && !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
: 0;
@@ -2500,6 +2342,7 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
const positionKey = `${state.session.login}:channel:${routeKey}`;
let disposed = false;
let refreshSeq = 0;
@@ -2526,11 +2369,12 @@ export function render({ navigate, route, chrome }) {
};
let activeChannelData = null;
let activeOpenEntrypointHistory = null;
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true;
const header = createTopBar({
@@ -2538,7 +2382,6 @@ export function render({ navigate, route, chrome }) {
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{
label: '⋮',
title: 'Действия канала',
@@ -2577,7 +2420,14 @@ export function render({ navigate, route, chrome }) {
},
});
}
items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({
label: 'Оглавление',
action: () => {
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
else showToast('В этом канале пока нет оглавления');
},
});
if (!apiData?.isOwnChannel) {
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
}
@@ -2595,6 +2445,8 @@ export function render({ navigate, route, chrome }) {
});
}
}
const aboutIndex = items.findIndex((item) => item.label === 'О канале');
if (aboutIndex > 0) items.unshift(...items.splice(aboutIndex, 2));
return items;
},
},
@@ -2918,6 +2770,8 @@ export function render({ navigate, route, chrome }) {
screen.append(statusBox);
const clearContent = () => {
chrome?.setComposer(null);
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null;
Array.from(screen.children).forEach((child) => {
@@ -2942,17 +2796,23 @@ export function render({ navigate, route, chrome }) {
'#reply-modal',
'#repost-modal',
].join(',');
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
if (modalRoot.querySelector(ownedSelector)) {
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
modalRoot.innerHTML = '';
}
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
const hadContent = !!screen.querySelector('.channel-feed');
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
if (!hadContent) clearContent();
activeChannelData = null;
activeOpenEntrypointHistory = null;
activeSelector = null;
showStatus('');
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true;
channelHeaderButton.onclick = null;
if (channelMoreButton) channelMoreButton.disabled = true;
@@ -2962,7 +2822,7 @@ export function render({ navigate, route, chrome }) {
channelEntrypointButton.onclick = null;
}
const skeleton = renderSkeleton(screen);
const skeleton = hadContent ? null : renderSkeleton(screen);
try {
const apiData = await loadFromApi(route, channelId);
@@ -2981,8 +2841,17 @@ export function render({ navigate, route, chrome }) {
},
});
};
activeOpenEntrypointHistory = openEntrypointHistory;
if (channelHeaderButton) {
channelHeaderButton.textContent = titleLabel;
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
channelHeaderButton.replaceChildren();
const titleNode = document.createElement('span');
titleNode.className = 'channel-header-title';
titleNode.textContent = titleLabel;
const ownerNode = document.createElement('span');
ownerNode.className = 'channel-header-owner';
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
channelHeaderButton.append(titleNode, ownerNode);
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
@@ -3009,8 +2878,11 @@ export function render({ navigate, route, chrome }) {
}
};
}
skeleton.remove();
skeleton?.remove();
clearContent();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
chrome,
restorePosition,
showStatus,
isActive: () => !disposed,
onAddMessage: () => {
@@ -3094,7 +2966,8 @@ export function render({ navigate, route, chrome }) {
});
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
skeleton?.remove();
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить канал.')); return; }
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error);
return;
@@ -3108,6 +2981,7 @@ export function render({ navigate, route, chrome }) {
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
rememberChannelPosition(positionKey);
disposed = true;
refreshSeq += 1;
clearContent();
+103 -46
View File
@@ -18,8 +18,9 @@ import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_STORIES = 0;
@@ -30,6 +31,7 @@ const DIARY_DISPLAY_NAME = 'Дневник';
const CHANNELS_VIEW_ALL = 'all';
const CHANNELS_VIEW_OWNED = 'owned';
const CHANNELS_VIEW_FOLLOWING = 'following';
const listQueries = new Map();
function channelMenuIcon(name) {
const paths = {
@@ -120,6 +122,7 @@ function createChannelAvatar(channel = {}) {
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'lg',
className: 'avatar-plain',
title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала',
});
@@ -847,16 +850,20 @@ function toListModel(groups) {
];
}
function renderEmptyState() {
function renderEmptyState(listState, navigate) {
const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
if (!state.session.isAuthorized) {
return wrap;
}
const heading = document.createElement('strong');
heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = 'У вас пока нет доступных каналов.';
wrap.append(text);
text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
const action = document.createElement('button');
action.type = 'button';
action.className = 'primary-btn';
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
wrap.append(heading, text, action);
return wrap;
}
@@ -962,7 +969,7 @@ function renderChannelMain(channel) {
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
technical.textContent = `@${channel.ownerName || 'автор'}`;
if (channel.channelDescription) {
const desc = document.createElement('p');
@@ -976,7 +983,7 @@ function renderChannelMain(channel) {
const preview = document.createElement('p');
preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
previewLine.append(preview);
@@ -997,10 +1004,15 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
return true;
}).filter((channel) => {
const query = String(listState.query || '').trim().toLowerCase();
if (!query) return true;
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
.some((value) => String(value || '').toLowerCase().includes(query));
});
if (!filtered.length) {
container.append(renderEmptyState());
container.append(renderEmptyState(listState, navigate));
return;
}
@@ -1010,10 +1022,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed });
filtered.forEach((channel) => {
const row = document.createElement('article');
const row = document.createElement('button');
row.type = 'button';
row.className = 'channel-row';
const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible);
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
const avatar = createChannelAvatar(channel);
@@ -1047,6 +1061,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
if (listState.disposed) return;
const seq = ++listState.loadSeq;
if (!silent) renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
@@ -1072,6 +1088,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
if (listState.disposed || seq !== listState.loadSeq) return;
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
// The server/API implementation is preserved so the feature can be restored later.
@@ -1096,7 +1113,12 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
navigate,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
});
if (Number.isFinite(listState.restorePosition)) {
restoreChannelPosition(listState.restorePosition);
listState.restorePosition = undefined;
}
} catch (error) {
if (listState.disposed || seq !== listState.loadSeq) return;
if (silent) return;
setChannelsFeed(null, {});
contentEl.innerHTML = '';
@@ -1118,52 +1140,81 @@ export function render({ navigate, route, chrome }) {
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
const listState = {
restorePosition: readChannelPosition(positionKey),
disposed: false,
loadSeq: 0,
notificationsState,
revealedCounters: new Set(),
channels: [],
viewMode: normalizeChannelsViewMode(route),
query: listQueries.get(positionKey) || '',
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
],
});
const topBarEl = createTopBar({
center: topTitle,
title: 'Каналы',
className: 'topbar--root',
actions: [
{
iconNode: createOverflowDots(),
title: 'Ещё действия',
ariaLabel: 'Ещё действия',
className: 'channels-top-more-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
],
},
label: '+',
title: 'Создать канал',
ariaLabel: 'Создать канал',
className: 'channels-create-btn',
onClick: () => navigate('add-channel-view'),
},
],
});
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const controls = document.createElement('div');
controls.className = 'channels-list-controls';
const searchWrap = document.createElement('div');
searchWrap.className = 'channels-inline-search';
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.value = listState.query;
searchInput.placeholder = 'В вашем списке';
searchInput.setAttribute('aria-label', 'Найти канал или автора');
const serverSearchButton = document.createElement('button');
serverSearchButton.type = 'button';
serverSearchButton.className = 'text-btn channels-server-search';
serverSearchButton.textContent = 'По @автору';
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
searchWrap.append(searchInput, serverSearchButton);
const tabs = document.createElement('div');
tabs.className = 'tabs tabs--three';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('aria-label', 'Фильтр каналов');
tabs.addEventListener('keydown', (event) => {
const buttons = [...tabs.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
buttons[next].focus();
});
[
[CHANNELS_VIEW_ALL, 'Все'],
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
[CHANNELS_VIEW_OWNED, 'Мои'],
].forEach(([mode, label]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tab-btn';
button.textContent = label;
button.setAttribute('role', 'tab');
const selected = listState.viewMode === mode;
button.classList.toggle('active', selected);
button.setAttribute('aria-selected', String(selected));
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
tabs.append(button);
});
controls.append(searchWrap, tabs);
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
@@ -1192,13 +1243,16 @@ export function render({ navigate, route, chrome }) {
refreshFeed: reloadFeed,
});
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
};
searchInput.addEventListener('input', () => {
listState.restorePosition = undefined;
listState.query = searchInput.value;
rerenderList();
});
chrome?.setTopbar(topBarEl);
screen.append(contentEl);
screen.append(controls, contentEl);
if (createSuccessFlash) {
showToast(createSuccessFlash);
@@ -1210,9 +1264,12 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
if (listState.disposed) return;
rememberChannelPosition(positionKey);
listQueries.set(positionKey, listState.query);
listState.disposed = true;
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
unsubscribeCountersChanged();
channelsFilterMenu.destroy();
};
return screen;
+14
View File
@@ -1,6 +1,7 @@
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -57,6 +58,15 @@ export function render({navigate, chrome}) {
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<div class="stack">
<label for="settings-theme"><strong>Оформление</strong></label>
<span class="meta-muted">Тема меняет только цвета и не перезагружает экран.</span>
<select class="select" id="settings-theme">
<option value="system">Как на устройстве</option>
<option value="light">Дневное</option>
<option value="dark">Ночное</option>
</select>
</div>
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
@@ -81,6 +91,10 @@ export function render({navigate, chrome}) {
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
const themeSelect = card.querySelector('#settings-theme');
themeSelect.value = getThemeMode();
themeSelect.addEventListener('change', () => setThemeMode(themeSelect.value));
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
@@ -0,0 +1,10 @@
// Состояние чтения живёт только в текущей вкладке и разделено по аккаунтам.
const positions = new Map();
export function rememberChannelPosition(key) {
positions.set(key, document.getElementById('app-screen')?.scrollTop || 0);
}
export function readChannelPosition(key) { return positions.get(key); }
export function restoreChannelPosition(value) {
const root = document.getElementById('app-screen');
if (root && Number.isFinite(value)) root.scrollTop = value;
}
+49
View File
@@ -0,0 +1,49 @@
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
const MODES = new Set(['system', 'light', 'dark']);
let sessionMode = null;
function normalizeMode(value) {
const mode = String(value || '').trim().toLowerCase();
return MODES.has(mode) ? mode : 'system';
}
export function getThemeMode() {
if (sessionMode !== null) return sessionMode;
try {
return normalizeMode(localStorage.getItem(STORAGE_KEY));
} catch {
return 'system';
}
}
export function applyThemeMode(mode = getThemeMode()) {
const normalized = normalizeMode(mode);
const resolved = normalized === 'system'
? (window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark')
: normalized;
document.documentElement.dataset.themeMode = normalized;
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
return { mode: normalized, resolved };
}
export function setThemeMode(mode) {
const normalized = normalizeMode(mode);
sessionMode = normalized;
try {
localStorage.setItem(STORAGE_KEY, normalized);
} catch {
// В приватном режиме тема всё равно применяется до закрытия страницы.
}
return applyThemeMode(normalized);
}
export function watchSystemTheme() {
const media = window.matchMedia?.('(prefers-color-scheme: light)');
if (!media) return () => {};
const onChange = () => {
if (getThemeMode() === 'system') applyThemeMode('system');
};
media.addEventListener?.('change', onChange);
return () => media.removeEventListener?.('change', onChange);
}
+10 -7
View File
@@ -3,7 +3,7 @@
* Feature-specific стили находятся в styles/features/* и network-graph.css.
*/
.app-shell {
width: min(var(--app-viewport-width, 100vw), 430px);
width: min(var(--app-viewport-width, 100vw), var(--shell-content-width, 430px));
height: var(--app-viewport-height, 100vh);
position: fixed;
top: var(--app-viewport-offset-top, 0px);
@@ -21,7 +21,7 @@
background: transparent;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
box-shadow: var(--shadow);
box-shadow: none;
overflow: hidden;
}
@@ -33,9 +33,11 @@
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
z-index: var(--z-shell-content);
overflow-y: auto;
padding: 14px 14px 24px;
padding: 0 16px 24px;
}
.app-shell--wide { --shell-content-width: 640px; }
.screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0;
@@ -74,9 +76,9 @@
.composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
width: 100%;
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
bottom: var(--toolbar-height, 78px);
padding: 0 12px;
pointer-events: none;
}
@@ -90,8 +92,9 @@
left: 0;
right: 0;
bottom: 0;
padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
padding: 0 4px env(safe-area-inset-bottom);
background: var(--background);
border-top: 1px solid var(--border-subtle);
transition: opacity 0.18s ease, transform 0.18s ease;
}
+9 -132
View File
@@ -1,135 +1,12 @@
/*
* Shared semantic button roles.
*
* Stage 4: styling is opt-in by an existing UI role class. Component-owned
* controls (TopBar, Dropdown, Toolbar, tabs, ScrollToBottom, etc.) are styled
* by their own owner stylesheet and are intentionally absent from this file.
*/
.ui-button,
.primary-btn,
.secondary-btn,
.destructive-btn,
.ghost-btn,
.icon-btn,
.text-btn,
.shine-btn {
color: #ffffff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
.ui-button:hover,
.primary-btn:hover,
.secondary-btn:hover,
.destructive-btn:hover,
.ghost-btn:hover,
.icon-btn:hover,
.text-btn:hover,
.shine-btn:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
.primary-btn:hover,
.secondary-btn:hover,
.destructive-btn:hover,
.ghost-btn:hover,
.icon-btn:hover,
.text-btn:hover,
.shine-btn:hover {
transform: translateY(-1px);
}
.ui-button:active,
.primary-btn:active,
.secondary-btn:active,
.destructive-btn:active,
.ghost-btn:active,
.icon-btn:active,
.text-btn:active,
.shine-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.ui-button:disabled,
.primary-btn:disabled,
.secondary-btn:disabled,
.destructive-btn:disabled,
.ghost-btn:disabled,
.icon-btn:disabled,
.text-btn:disabled,
.shine-btn:disabled,
.ui-button[aria-disabled='true'],
.primary-btn[aria-disabled='true'],
.secondary-btn[aria-disabled='true'],
.destructive-btn[aria-disabled='true'],
.ghost-btn[aria-disabled='true'],
.icon-btn[aria-disabled='true'],
.text-btn[aria-disabled='true'],
.shine-btn[aria-disabled='true'] {
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
}
/* Decorative button pseudo-elements were not part of the Stage 3.1 surface. */
.ui-button::before,
.primary-btn::before,
.secondary-btn::before,
.destructive-btn::before,
.ghost-btn::before,
.icon-btn::before,
.text-btn::before,
.shine-btn::before,
.ui-button::after,
.primary-btn::after,
.secondary-btn::after,
.destructive-btn::after,
.ghost-btn::after,
.icon-btn::after,
.text-btn::after,
.shine-btn::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.ui-button:focus-visible,
.primary-btn:focus-visible,
.secondary-btn:focus-visible,
.destructive-btn:focus-visible,
.ghost-btn:focus-visible,
.icon-btn:focus-visible,
.text-btn:focus-visible,
.shine-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
/* Семантические роли кнопок. Геометрией сложных компонентов владеют их стили. */
.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn { color: var(--text-primary); background: transparent; border: 0; box-shadow: none; font: inherit; cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }
.primary-btn { color: var(--on-accent); background: var(--accent); }
.secondary-btn { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); }
.destructive-btn { color: var(--danger); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):hover { filter: brightness(.96); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):active { filter: brightness(.9); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):disabled { opacity: .5; cursor: not-allowed; filter: none; }
/* Explicit filled-action variant used by pre-auth/settings/profile screens. */
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close) {
color: #ffffff;
+318 -33
View File
@@ -1,4 +1,289 @@
/* Shared attachment UI styles. */
.message-attachments {
display: grid;
gap: 0.5rem;
}
.message-attachment-carousel {
gap: 0.35rem;
margin: 0.5rem 0 0.4rem;
max-width: min(100%, 34rem);
}
.message-attachment-carousel-viewport {
align-items: center;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 14px;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
touch-action: pan-y;
}
.message-attachment-carousel-viewport.has-landscape-media {
align-items: start;
min-height: 0;
}
.message-attachment-slide {
align-items: stretch;
display: grid;
min-height: 12rem;
}
.message-attachment-slide.has-landscape-media {
min-height: 0;
}
.message-attachment-arrow {
align-items: center;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
font-size: 1.85rem;
height: 44px;
justify-content: center;
line-height: 1;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
z-index: 2;
}
.message-attachment-arrow[hidden] {
display: none;
}
.message-attachment-arrow:disabled {
cursor: default;
opacity: 0.28;
}
.message-attachment-arrow--prev {
left: 0.55rem;
}
.message-attachment-arrow--next {
right: 0.55rem;
}
.message-attachment-counter {
color: var(--text-secondary);
font-size: 0.78rem;
text-align: center;
}
.message-attachment-media-frame {
align-items: center;
background: var(--surface);
cursor: zoom-in;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
}
.message-attachment-media-frame.is-landscape {
min-height: 0;
}
.message-attachment-media {
display: block;
height: min(52vh, 20rem);
max-height: 20rem;
object-fit: contain;
width: 100%;
}
.message-attachment-media.is-landscape {
height: auto;
}
.message-attachment-play {
align-items: center;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 999px;
color: var(--text-primary);
display: inline-flex;
font-size: 1.7rem;
height: 4rem;
justify-content: center;
left: 50%;
padding-left: 0.18rem;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4rem;
}
.message-attachment-download {
display: inline-flex;
align-items: center;
min-height: 44px;
background: var(--accent);
border-radius: 999px;
color: var(--on-accent);
font-size: 0.82rem;
font-weight: 700;
padding: 0.45rem 0.75rem;
position: relative;
text-decoration: none;
width: max-content;
z-index: 3;
}
.message-attachment-media-frame > .message-attachment-download {
bottom: 0.75rem;
position: absolute;
right: 0.75rem;
}
.message-attachment-file-card {
align-items: center;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 0.8rem;
color: var(--text-primary);
cursor: pointer;
display: grid;
gap: 0.55rem;
grid-template-columns: auto minmax(0, 1fr);
min-height: 3.25rem;
padding: 0.55rem 0.7rem;
}
.message-attachment-file-ext {
align-items: center;
background: var(--accent);
border-radius: 999px;
color: var(--on-accent);
display: inline-flex;
font-size: 0.72rem;
font-weight: 800;
height: 1.7rem;
justify-content: center;
min-width: 3.25rem;
padding: 0 0.55rem;
}
.message-attachment-file-main {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.message-attachment-file-main .message-attachment-meta {
color: var(--text-secondary);
}
.message-attachment-unavailable {
align-items: center;
background: var(--surface);
color: var(--danger);
display: flex;
font-weight: 700;
justify-content: center;
min-height: 12rem;
padding: 1.25rem;
text-align: center;
}
.message-attachment-card {
align-items: center;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 1rem;
color: var(--text-primary);
display: flex;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
text-decoration: none;
}
.message-attachment-icon {
align-items: center;
background: var(--surface);
border-radius: 0.8rem;
color: var(--text-primary);
display: inline-flex;
flex: 0 0 auto;
height: 2.1rem;
justify-content: center;
width: 2.1rem;
}
.message-attachment-main {
display: grid;
min-width: 0;
}
.message-attachment-name {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment-meta {
color: var(--text-secondary);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.message-attachment-carousel {
max-width: 100%;
}
.message-attachment-carousel-viewport,
.message-attachment-slide,
.message-attachment-media-frame,
.message-attachment-unavailable {
min-height: 10rem;
}
.message-attachment-media {
height: min(42vh, 16rem);
}
.message-attachment-file-card {
grid-template-columns: auto minmax(0, 1fr);
}
.message-attachment-file-card .message-attachment-download {
grid-column: 1 / -1;
}
}
.ar-attachment-manager-root .modal { z-index: 120; }
.attachment-viewer-modal {
z-index: 80;
@@ -6,11 +291,11 @@
.attachment-viewer-card {
background: rgba(15, 23, 42, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 1.2rem;
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
color: #fff;
box-shadow: none;
color: var(--text-primary);
display: grid;
gap: 0.8rem;
max-height: 92vh;
@@ -47,7 +332,7 @@
.attachment-viewer-media {
background: #020617;
background: var(--background);
border-radius: 0.8rem;
max-height: 76vh;
max-width: 100%;
@@ -66,7 +351,7 @@
.ar-attachment-meta {
color: #cbd5e1;
color: var(--text-secondary);
display: grid;
font-size: 0.86rem;
gap: 0.25rem;
@@ -76,7 +361,7 @@
.ar-attachment-wallet-select,
.ar-attachment-wallet-select option {
color: #38bdf8;
color: var(--accent);
}
@@ -96,7 +381,7 @@
.ar-attachment-history-table th,
.ar-attachment-history-table td {
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
border-bottom: 1px solid var(--border-subtle);
padding: 0.55rem;
text-align: left;
vertical-align: top;
@@ -104,7 +389,7 @@
.ar-attachment-history-table th {
color: #cbd5e1;
color: var(--text-secondary);
font-weight: 700;
}
@@ -121,7 +406,7 @@
overflow-x: hidden;
overflow-y: auto;
padding: 0.35rem;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
scrollbar-color: var(--border-control) var(--border-subtle);
scrollbar-width: thin;
width: 100%;
}
@@ -135,27 +420,27 @@
.ar-attachment-history-tiles::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
background: var(--border-subtle);
}
.ar-attachment-history-tiles::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.7);
background: var(--border-control);
border-radius: 999px;
}
.ar-attachment-history-tiles::-webkit-scrollbar-thumb:hover {
background: rgba(240, 198, 76, 0.9);
background: var(--border-control);
}
.ar-attachment-history-tile {
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(22, 36, 53, 0.96));
background: var(--surface);
border: 1px solid transparent;
border-radius: 0.52rem;
box-sizing: border-box;
color: #f8fafc;
color: var(--text-primary);
display: grid;
gap: 0.12rem;
max-width: 100%;
@@ -167,13 +452,13 @@
.ar-attachment-history-tile.is-selected {
border-color: rgba(34, 197, 94, 0.55);
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.18);
border-color: var(--success);
box-shadow: none;
}
.ar-attachment-history-tile--page {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
box-shadow: none;
}
@@ -185,7 +470,7 @@
.ar-attachment-history-preview {
align-items: flex-end;
background: rgba(255, 255, 255, 0.05);
background: var(--border-subtle);
border-radius: 0.42rem;
display: flex;
justify-content: space-between;
@@ -204,11 +489,11 @@
.ar-attachment-history-preview-badge {
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 999px;
bottom: 0.42rem;
color: #f8fafc;
color: var(--text-primary);
font-size: 0.62rem;
left: 0.42rem;
padding: 0.2rem 0.46rem;
@@ -245,7 +530,7 @@
.ar-attachment-history-meta-row {
align-items: center;
color: #cbd5e1;
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
font-size: 0.6rem;
@@ -256,9 +541,9 @@
.ar-attachment-history-txid {
background: rgba(255, 255, 255, 0.06);
background: var(--border-subtle);
border-radius: 0.34rem;
color: rgba(226, 232, 240, 0.86);
color: var(--text-secondary);
font-size: 0.56rem;
line-height: 1.05;
max-width: 100%;
@@ -281,27 +566,27 @@
.ar-attachment-status--available {
background: rgba(220, 252, 231, 0.96);
color: #166534;
background: var(--surface-selected);
color: var(--success);
}
.ar-attachment-status--pending {
background: rgba(254, 249, 195, 0.96);
color: #854d0e;
background: var(--surface-selected);
color: var(--warning);
}
.ar-attachment-status--unavailable {
background: rgba(254, 226, 226, 0.96);
color: #991b1b;
background: var(--surface-selected);
color: var(--danger);
}
.ar-attachment-placement-flag {
background: rgba(219, 234, 254, 0.96);
background: var(--surface-selected);
border-radius: 999px;
color: #1e3a8a;
color: var(--accent);
flex: 0 0 auto;
font-size: 0.58rem;
font-weight: 800;
+7
View File
@@ -289,3 +289,10 @@
z-index: 4;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
}
/* Спокойный вариант для текстовых лент; фото остаётся без фильтров. */
.avatar.avatar-image.avatar-plain { overflow: hidden; background: var(--surface-selected); color: var(--accent); }
.avatar.avatar-image.avatar-plain::after { content: none; }
.avatar.avatar-image.avatar-plain > .avatar-fallback,
.avatar.avatar-image.avatar-plain > .avatar-photo { width: 100%; height: 100%; box-shadow: none; text-shadow: none; }
.avatar.avatar-image.avatar-plain > .avatar-fallback { background: var(--surface-selected); color: var(--accent); }
@@ -0,0 +1,219 @@
.channel-editor-overlay {
position: fixed;
top: var(--editor-top, 0px);
left: 0;
right: 0;
height: var(--editor-height, 100dvh);
z-index: 90;
display: grid;
background: var(--scrim);
}
.channel-editor {
width: min(100%, 640px);
height: 100%;
min-height: 0;
margin-inline: auto;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
color: var(--text-primary);
background: var(--background);
}
.channel-editor__header {
min-height: calc(56px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 6px 0;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
align-items: center;
border-bottom: 1px solid var(--border-subtle);
}
.channel-editor__header h2 {
font-size: 16px;
line-height: 24px;
font-weight: 600;
text-align: center;
}
.channel-editor__close {
width: 44px;
height: 44px;
padding: 0;
font-size: 28px;
color: var(--text-primary);
}
.channel-editor__body {
min-height: 0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.channel-editor__context {
padding-left: 12px;
border-left: 2px solid var(--accent);
color: var(--text-secondary);
}
.channel-editor__context strong {
display: block;
margin-bottom: 4px;
color: var(--text-primary);
font-size: 13px;
}
.channel-editor__context p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 13px;
line-height: 18px;
overflow-wrap: anywhere;
}
.channel-editor__context.is-expanded p { display: block; }
.channel-editor__context-toggle,
.channel-editor__clear {
min-height: 44px;
padding: 6px 0;
color: var(--accent);
font-size: 13px;
}
.channel-editor__input {
flex: 1 0 auto;
overflow-y: hidden;
width: 100%;
min-height: 160px;
padding: 0;
resize: none;
border: 0;
outline: 0;
color: var(--text-primary);
background: transparent;
font: inherit;
font-size: 16px;
line-height: 24px;
caret-color: var(--accent);
}
.channel-editor__input::placeholder { color: var(--text-secondary); }
.channel-editor__type {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.channel-editor__type .select {
color: var(--text-primary);
background: var(--surface);
border-color: var(--border-control);
}
.channel-editor__input:focus-visible {
border-radius: 4px;
outline: 2px solid var(--focus-ring);
outline-offset: 4px;
}
.channel-editor__attachments { display: grid; gap: 8px; }
.channel-editor__author { display: flex; align-items: center; gap: 12px; font-size: .875rem; }
.channel-editor__author .avatar { width: 40px; height: 40px; min-width: 40px; min-height: 40px; }
.channel-editor__context-toggle[hidden] { display: none; }
.channel-editor__attach[hidden], .channel-editor__clear[hidden] { display: none; }
.channel-editor__attachment {
min-height: 44px;
padding: 6px 8px 6px 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
align-items: center;
gap: 8px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--surface);
font-size: 13px;
}
.channel-editor__attachment span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-editor__attachment button { min-width: 44px; min-height: 44px; color: var(--text-secondary); }
.channel-editor__error {
min-height: 18px;
color: var(--danger);
font-size: 13px;
line-height: 18px;
}
.channel-editor__footer {
min-height: calc(68px + env(safe-area-inset-bottom));
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-top: 1px solid var(--border-subtle);
background: var(--background);
}
.channel-editor__attach,
.channel-editor__submit {
min-height: 44px;
padding: 0 14px;
border-radius: 12px;
}
.channel-editor__attach {
color: var(--accent);
border: 1px solid var(--border-control);
background: var(--surface);
}
.channel-editor__submit {
color: var(--on-accent);
background: var(--accent);
}
.channel-editor__counter {
min-width: 0;
color: var(--text-secondary);
font-size: 12px;
text-align: right;
}
.channel-editor__counter.is-near-limit { color: var(--warning); }
@media (min-width: 680px) {
.channel-editor-overlay { place-items: center; padding: 24px; }
.channel-editor {
height: min(760px, 100%);
border: 1px solid var(--border-subtle);
border-radius: 20px;
box-shadow: var(--shadow);
}
}
@media (max-width: 370px) {
.channel-editor__footer { grid-template-columns: auto minmax(0, 1fr); }
.channel-editor__counter { grid-column: 1 / -1; grid-row: 1; }
.channel-editor__attach { grid-column: 1; }
.channel-editor__submit { grid-column: 2; }
}
@media (prefers-reduced-motion: reduce) {
.channel-editor-overlay, .channel-editor { animation: none; transition: none; }
}
+12 -145
View File
@@ -1,145 +1,12 @@
:root {
--z-overlay: 40;
--z-dropdown: 50;
--z-modal: 60;
}
.dropdown-portal {
pointer-events: none;
}
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
pointer-events: auto;
background: rgba(2, 6, 14, 0.34);
backdrop-filter: blur(1.5px);
-webkit-backdrop-filter: blur(1.5px);
}
.dropdown-menu {
position: fixed;
z-index: var(--z-dropdown);
display: grid;
gap: 2px;
padding: 6px;
min-width: 196px;
max-width: calc(100vw - 20px);
border: 1px solid rgba(92, 190, 255, 0.22);
border-radius: 16px;
background: linear-gradient(155deg, rgba(19, 27, 42, 0.97), rgba(7, 12, 22, 0.98));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), 0 0 24px rgba(39, 141, 255, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(20px) saturate(125%);
-webkit-backdrop-filter: blur(20px) saturate(125%);
pointer-events: auto;
}
.dropdown-menu--transparent {
border-color: rgba(92, 190, 255, 0.06);
background: rgba(7, 12, 22, 0.44);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.dropdown-menu__item {
width: 100%;
min-height: 42px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
border: 0;
border-radius: 10px;
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.dropdown-menu__item.is-selected {
background: rgba(39, 141, 255, 0.12);
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: none;
filter: none;
}
.dropdown-menu__item:focus-visible {
color: #ffffff;
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
background: rgba(39, 141, 255, 0.12);
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item.is-danger { color: #ffb7c5; }
.dropdown-menu__item.is-danger:hover,
.dropdown-menu__item.is-danger:active { color: #ffffff; }
.dropdown-menu__item:disabled,
.dropdown-menu__item.is-danger:disabled {
opacity: 0.55;
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
cursor: default;
}
.dropdown-menu__item::before,
.dropdown-menu__item::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.dropdown-menu__icon,
.dropdown-menu__icon svg,
.dropdown-menu__item > img {
width: 18px;
height: 18px;
min-width: 18px;
flex: 0 0 18px;
object-fit: contain;
color: #65b7ff;
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
}
.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: rgba(255, 255, 255, 0.08); }
@media (prefers-reduced-motion: reduce) { .dropdown-menu { animation: none; } }
:root { --z-overlay: 100; --z-dropdown: 110; --z-modal: 120; }
.dropdown-portal { pointer-events: none; }
.dropdown-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); pointer-events: auto; background: var(--scrim); }
.dropdown-menu { position: fixed; z-index: var(--z-dropdown); display: grid; gap: 2px; padding: 6px; min-width: 196px; max-width: calc(100vw - 20px); max-height: calc(100dvh - 20px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 20px; background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow); pointer-events: auto; }
.dropdown-menu__item { width: 100%; min-height: 44px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font: inherit; font-size: .875rem; line-height: 1.4; text-align: left; cursor: pointer; }
.dropdown-menu__item:hover, .dropdown-menu__item.is-selected { background: var(--surface-selected); }
.dropdown-menu__item:active { background: var(--surface-selected); }
.dropdown-menu__item:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.dropdown-menu__item.is-danger { color: var(--danger); }
.dropdown-menu__item:disabled { opacity: .5; cursor: not-allowed; }
.dropdown-menu__icon, .dropdown-menu__icon svg, .dropdown-menu__item > img { width: 22px; height: 22px; flex: 0 0 22px; object-fit: contain; color: currentColor; }
.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: var(--border-subtle); }
+17 -175
View File
@@ -1,178 +1,20 @@
/* Shared modal/toast/skeleton primitives. */
.modal-title {
font-size: 19px;
line-height: 1.2;
color: #f1d69a;
}
.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);
}
.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;
}
.modal-card {
background: linear-gradient(165deg, rgba(16, 31, 58, 0.97), rgba(10, 18, 36, 0.97));
border-color: transparent;
box-shadow: 0 20px 38px rgba(2, 6, 12, 0.55);
}
/* ===== Channels UX Stabilization ===== */
.toast-host {
position: fixed;
left: 0;
right: 0;
bottom: calc(18px + env(safe-area-inset-bottom));
display: grid;
justify-items: center;
gap: 8px;
z-index: 60;
pointer-events: none;
}
.toast {
min-width: min(88vw, 320px);
max-width: min(92vw, 420px);
border-radius: 14px;
padding: 11px 14px;
border: 1px solid transparent;
color: #f2dca8;
background: rgba(10, 14, 23, 0.8);
backdrop-filter: blur(12px);
box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.toast.is-hiding {
opacity: 0;
transform: translateY(8px);
}
.toast.toast--error {
color: #ffd8e0;
border-color: rgba(234, 122, 150, 0.45);
}
.skeleton-card {
display: grid;
gap: 8px;
}
.skeleton-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(100deg, rgba(93, 117, 154, 0.10), rgba(167, 191, 226, 0.24), rgba(93, 117, 154, 0.10));
background-size: 220% 100%;
animation: channels-shimmer 1.2s linear infinite;
}
/* Общие диалоги, уведомления и загрузка. */
.modal-title { font-size: 1rem; line-height: 1.5rem; font-weight: 600; color: var(--text-primary); }
.modal-shell[hidden] { display: none; }
.modal-shell { position: fixed; inset: 0; z-index: 60; }
.modal-backdrop { position: absolute; inset: 0; background: var(--scrim); }
.modal-dialog { position: absolute; left: 16px; right: 16px; bottom: 24px; display: grid; gap: 12px; box-shadow: var(--shadow); }
.modal { position: fixed; inset: 0; background: var(--scrim); display: grid; place-items: center; padding: 16px; z-index: 60; }
.modal-card { width: min(100%, 560px); max-height: calc(100dvh - 32px); overflow-y: auto; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 20px; padding: 20px; box-shadow: var(--shadow); }
.toast-host { position: fixed; left: 0; right: 0; bottom: calc(18px + env(safe-area-inset-bottom)); display: grid; justify-items: center; gap: 8px; z-index: 140; pointer-events: none; }
.toast { max-width: min(92vw,420px); padding: 12px 16px; border-radius: 12px; color: var(--text-primary); background: var(--surface); box-shadow: var(--shadow); border: 1px solid var(--border-subtle); opacity: 0; transition: opacity 160ms; }
.toast.is-visible { opacity: 1; }
.toast.is-hiding { opacity: 0; }
.toast.toast--error { color: var(--danger); }
.skeleton-card { display: grid; gap: 12px; }
.skeleton-line { height: 12px; border-radius: 6px; background: var(--surface-selected); animation: skeleton-pulse 1.2s ease-in-out infinite alternate; }
.skeleton-line.w-40 { width: 40%; }
.skeleton-line.w-70 { width: 70%; }
.skeleton-line.w-90 { width: 90%; }
#about-channel-modal.modal,
#edit-channel-modal.modal,
#reply-modal.modal,
#channel-message-modal.modal,
#channel-edit-description-modal.modal,
#channels-subscribe-modal.modal,
#thread-reply-modal.modal {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#about-channel-modal .modal-card,
#edit-channel-modal .modal-card,
#reply-modal .modal-card,
#channel-message-modal .modal-card,
#channel-edit-description-modal .modal-card,
#channels-subscribe-modal .modal-card,
#thread-reply-modal .modal-card {
background: rgba(15, 18, 30, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid transparent;
border-radius: 20px;
padding: 24px;
}
#about-channel-modal .modal-title,
#edit-channel-modal .modal-title,
#reply-modal .modal-title,
#channel-message-modal .modal-title,
#channel-edit-description-modal .modal-title,
#channels-subscribe-modal .modal-title,
#thread-reply-modal .modal-title {
color: rgba(255, 200, 50, 0.95);
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
@keyframes skeleton-pulse { to { opacity: .4; } }
@media (prefers-reduced-motion: reduce) { .skeleton-line, .toast { animation: none; transition: none; } }
+19 -77
View File
@@ -15,18 +15,9 @@
.ghost-btn {
border-radius: var(--radius-sm);
padding: 9px 12px;
min-height: 38px;
min-height: 44px;
font-weight: 600;
cursor: pointer;
}
.icon-btn,
.text-btn,
.primary-btn,
.secondary-btn,
.destructive-btn,
.ghost-btn {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
@@ -41,14 +32,14 @@
.secondary-btn:disabled,
.destructive-btn:disabled,
.ghost-btn:disabled {
opacity: 1;
opacity: .5;
cursor: not-allowed;
}
.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);
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 14px;
}
@@ -111,14 +102,14 @@
.field-label {
color: #b2c2e6;
color: var(--text-secondary);
font-size: 13px;
}
.select {
width: 100%;
border: 1px solid var(--line);
border: 1px solid var(--border-control);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
min-height: 44px;
@@ -129,8 +120,8 @@
.select:focus {
outline: none;
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
border-color: var(--focus-ring);
box-shadow: 0 0 0 2px var(--focus-ring);
}
@@ -151,7 +142,7 @@
.status-line.is-unavailable {
color: #ff8d97;
color: var(--danger);
}
@@ -355,22 +346,22 @@
padding: 0 12px;
width: 100%;
outline: none;
color: #f3f7ff;
color: var(--text-primary);
font-size: 16px;
line-height: 1.4;
caret-color: #f1d18a;
-webkit-text-fill-color: #f3f7ff;
caret-color: var(--accent);
-webkit-text-fill-color: var(--text-primary);
}
.input:focus {
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
border-color: var(--focus-ring);
box-shadow: 0 0 0 2px var(--focus-ring);
}
.input::placeholder {
color: rgba(190, 206, 238, 0.82);
color: var(--text-secondary);
}
@@ -380,8 +371,8 @@ input.input:-webkit-autofill:focus,
textarea.input:-webkit-autofill,
textarea.input:-webkit-autofill:hover,
textarea.input:-webkit-autofill:focus {
-webkit-text-fill-color: #f3f7ff !important;
caret-color: #f1d18a;
-webkit-text-fill-color: var(--text-primary) !important;
caret-color: var(--accent);
border-color: var(--line);
box-shadow: 0 0 0 1000px rgba(11, 24, 46, 0.96) inset !important;
transition: background-color 9999s ease-out 0s;
@@ -396,6 +387,7 @@ textarea.input {
.inline-error {
color: var(--danger);
min-height: 18px;
}
@@ -495,7 +487,7 @@ textarea.input {
.section-title {
font-size: 14px;
font-weight: 700;
color: #dbe7ff;
color: var(--text-primary);
margin: 4px 2px;
}
@@ -505,56 +497,6 @@ textarea.input {
}
#about-channel-modal .input,
#edit-channel-modal .input,
#reply-modal .input,
#channel-message-modal .input,
#channel-edit-description-modal .input,
#channels-subscribe-modal .input,
#thread-reply-modal .input {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #ffffff;
padding: 12px 16px;
font-size: 14px;
resize: vertical;
width: 100%;
}
#channel-edit-description-counter,
#channel-edit-description-modal #channel-description-counter {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
}
#about-channel-modal .secondary-btn,
#edit-channel-modal .secondary-btn,
#reply-modal .secondary-btn,
#channel-message-modal .secondary-btn,
#channel-edit-description-modal .secondary-btn,
#channels-subscribe-modal .secondary-btn,
#thread-reply-modal .secondary-btn {
border-radius: 12px;
padding: 13px 24px;
}
#about-channel-modal .primary-btn,
#edit-channel-modal .primary-btn,
#reply-modal .primary-btn,
#channel-message-modal .primary-btn,
#channel-edit-description-modal .primary-btn,
#channels-subscribe-modal .primary-btn,
#thread-reply-modal .primary-btn {
border-radius: 12px;
padding: 13px 24px;
font-weight: 600;
}
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
даёт лишний скролл. Фон НЕ меняем клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
html,
+10 -4
View File
@@ -1,16 +1,19 @@
/* Shared tab geometry and tab primitives. */
.tabs {
min-height: 44px;
display: grid;
grid-template-columns: repeat(2, 1fr);
border: 1px solid rgba(255, 255, 255, 0.08);
gap: 4px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 4px;
background: rgba(255, 255, 255, 0.02);
background: var(--surface);
}
.tabs--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.tab-btn {
min-height: 44px;
border: 0;
background: transparent;
border-radius: 9px;
@@ -21,9 +24,12 @@
.tab-btn.active {
background: rgba(83, 216, 251, 0.16);
background: var(--surface-selected);
color: var(--text);
}
.tab-btn:hover { color: var(--text-primary); }
.tab-btn:active { transform: scale(.98); }
.tab-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
+13 -317
View File
@@ -1,317 +1,13 @@
/* Bottom toolbar component styles. */
.toolbar-item,
.toolbar-action,
.toolbar-button {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* Базовая поверхность toolbar: финальные effective declarations Этапа 3.1. */
.toolbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
min-height: 82px;
padding: 9px 9px 6px;
align-items: end;
overflow: visible;
background: transparent;
border: 0;
border-radius: 20px;
box-shadow: 0 18px 32px rgba(2, 6, 13, 0.62);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.toolbar-btn {
position: relative;
min-height: 52px;
padding: 4px 3px 2px;
display: grid;
align-content: end;
justify-items: center;
gap: 2px;
border: 0;
border-radius: 12px;
background: transparent;
color: #b8c7ea;
font-size: 11px;
cursor: pointer;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
color 120ms ease;
}
.toolbar-btn.active {
background: transparent;
border: 1px solid transparent;
box-shadow: none;
color: #D4AF37;
}
.toolbar-btn.active span:first-child {
color: #D4AF37;
filter: drop-shadow(0 0 10px rgba(212, 175, 55, 0.6));
}
.toolbar-btn.active span:last-child {
color: #D4AF37;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
}
.toolbar-btn:active {
transform: translateY(1px) scale(0.96);
background: rgba(0, 0, 0, 0.14);
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
}
.toolbar-btn-network {
position: relative;
width: calc(100% + 6px);
min-height: 72px;
margin-inline: -3px;
padding-bottom: 2px;
align-content: end;
overflow: visible;
-webkit-tap-highlight-color: transparent;
}
.toolbar-label-wrap {
display: grid;
justify-items: center;
line-height: 1.05;
}
.toolbar-btn-profile {
position: relative;
}
.toolbar-btn-messages {
position: relative;
}
.toolbar-unread-badge {
position: absolute;
top: 2px;
right: 6px;
min-width: 16px;
height: 16px;
border-radius: 999px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f07f8a;
color: #fff2f4;
border: 1px solid rgba(255, 222, 227, 0.55);
box-shadow: 0 4px 10px rgba(152, 36, 52, 0.35);
font-size: 10px;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.toolbar-btn-profile .toolbar-label-wrap {
padding-bottom: 0;
}
.toolbar-btn-profile .toolbar-connection-indicator {
display: none;
}
.toolbar-connection-indicator {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 9px;
}
.toolbar-connection-text {
display: none;
font-size: 8px;
letter-spacing: 0.02em;
text-transform: lowercase;
color: rgba(191, 213, 255, 0.8);
opacity: 0;
transition: opacity 0.2s ease;
}
.toolbar-connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(148, 162, 195, 0.65);
box-shadow: 0 0 0 2px rgba(97, 116, 156, 0.25);
transition: 0.2s ease;
}
.toolbar-connection-indicator.is-connected .toolbar-connection-text {
opacity: 1;
color: rgba(145, 255, 192, 0.9);
}
.toolbar-connection-indicator.is-connected .toolbar-connection-dot {
background: #71e9a5;
box-shadow: 0 0 0 2px rgba(72, 201, 134, 0.28);
}
.toolbar-connection-indicator.is-connecting .toolbar-connection-dot {
background: #f0c56b;
}
.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot {
background: #e48792;
box-shadow: 0 0 0 2px rgba(228, 135, 146, 0.24);
}
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
.toolbar-icon-img {
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
width: var(--tab-icon-size);
height: var(--tab-icon-size);
object-fit: contain;
display: block;
transition: transform .12s ease, filter .15s ease;
}
/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */
.toolbar-btn.active .toolbar-icon-img {
filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08);
}
/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */
.toolbar-btn:active .toolbar-icon-img {
transform: scale(0.9);
filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2);
}
/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */
.toolbar-btn-hero .toolbar-icon-img {
/* Крупнее ВИЗУАЛЬНО через transform (origin center) раскладочный размер как у остальных (27px),
поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */
transform: scale(1.63); /* ≈44px при базовых 27px */
filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */
}
.toolbar-btn-hero.active .toolbar-icon-img {
filter: brightness(1.12);
}
.toolbar-btn-hero:active .toolbar-icon-img {
transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */
filter: brightness(1.25); /* нажатие — только подсветление, без ореола */
}
.toolbar-channels-hold-overlay {
position: fixed;
z-index: 1200;
transform: translate(-50%, -100%);
display: grid;
grid-template-columns: repeat(3, minmax(68px, 1fr));
gap: 6px;
padding: 8px;
border-radius: 10px;
background: rgba(15, 18, 31, 0.94);
border: 1px solid rgba(160, 175, 220, 0.35);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
}
.toolbar-channels-hold-item {
border: 1px solid rgba(160, 175, 220, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #e9efff;
font-size: 12px;
line-height: 1.2;
min-height: 34px;
padding: 6px 8px;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.toolbar-channels-hold-item.is-active {
background: rgba(133, 170, 255, 0.34);
border-color: rgba(197, 219, 255, 0.75);
}
/* Cosmic styling for the "Связи" toolbar button */
/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */
.toolbar-btn-network:focus,
.toolbar-btn-network:focus-visible {
outline: none;
}
.toolbar-btn-network::before {
content: "";
display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */
position: absolute;
inset: 6px;
border-radius: 10px;
pointer-events: none;
opacity: 0.42;
background:
radial-gradient(circle at 24% 24%, rgba(112, 170, 255, 0.35), transparent 56%),
radial-gradient(circle at 78% 72%, rgba(197, 132, 255, 0.24), transparent 60%);
filter: blur(8px);
}
.toolbar-btn-network span:first-child {
color: #9eb3e8;
filter: drop-shadow(0 0 5px rgba(123, 170, 255, 0.24));
}
/* ===== Единая полировка меню, DM и управляющих кнопок (2026-08-26) ===== */
/* Центральная вкладка остаётся на прежней оси иконок, но имеет большую
* интерактивную область; подпись опущена ниже и больше не заходит на логотип. */
.toolbar-btn-network > span:last-child {
display: block;
position: relative;
transform: translateY(-2px);
line-height: 1.05;
padding-bottom: 1px;
}
/* Toolbar: reserve enough vertical room for the Connections label and safe area. */
:root { --toolbar-height: 88px; }
.toolbar-slot {
min-height: calc(88px + env(safe-area-inset-bottom));
overflow: visible;
padding-bottom: env(safe-area-inset-bottom);
}
.toolbar-btn-network .toolbar-icon-img {
transform: translateY(-4px);
}
/* Пять разделов с одинаковыми действиями и доступными подписями. */
.toolbar { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); min-height: 64px; gap: 0; padding: 4px 0; background: var(--background); }
.toolbar-btn { position: relative; min-width: 0; min-height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 4px 1px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); font-size: .6875rem; line-height: 1rem; overflow-wrap: anywhere; cursor: pointer; }
.toolbar-btn > svg { width: 36px; height: 28px; padding: 3px 7px; border-radius: 10px; flex-shrink: 0; }
.toolbar-btn.active { color: var(--accent); }
.toolbar-btn.active > svg { background: var(--surface-selected); }
.toolbar-btn:hover, .toolbar-btn:active { background: var(--surface); }
.toolbar-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.toolbar-label-wrap { display: flex; align-items: center; gap: 3px; }
.toolbar-connection-dot { display: block; width: 4px; height: 4px; border-radius: 50%; background: var(--text-secondary); }
.toolbar-connection-indicator.is-connected .toolbar-connection-dot { background: var(--success); }
.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot { background: var(--danger); }
.toolbar-unread-badge { position: absolute; top: 0; right: 6px; min-width: 16px; padding: 0 4px; border-radius: 12px; color: var(--on-accent); background: var(--accent); font-size: .625rem; line-height: 16px; }
+16 -229
View File
@@ -1,230 +1,17 @@
:root {
--app-topbar-base-height: 64px;
--app-topbar-title-size: 18px;
--app-topbar-bg: #05070A;
--app-topbar-fg: #F7FBFF;
--app-topbar-gold: var(--app-topbar-fg);
--app-topbar-blue-glow: rgba(92, 190, 255, 0.72);
--app-topbar-blue-glow-soft: rgba(72, 145, 255, 0.34);
}
.topbar {
width: 100%;
min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
margin: 0 0 14px;
padding: calc(10px + env(safe-area-inset-top)) 0 10px;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr);
align-items: center;
gap: 8px;
}
.topbar-slot .topbar {
height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
max-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
margin: 0;
padding-top: env(safe-area-inset-top);
padding-bottom: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.topbar__left,
.topbar__center,
.topbar__right {
min-width: 0;
display: flex;
align-items: center;
}
.topbar__left { width: 100%; justify-content: flex-start; gap: 8px; }
.topbar__center { justify-content: center; overflow: hidden; text-align: center; }
.topbar__right { width: 100%; justify-content: flex-end; gap: 8px; }
.topbar__title {
margin: 0;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-main);
font-size: var(--app-topbar-title-size);
font-weight: 700;
line-height: 22px;
letter-spacing: 0;
color: var(--app-topbar-fg);
text-align: center;
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 14px var(--app-topbar-blue-glow-soft);
}
.topbar__left-label {
display: inline-block;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #a8bcdf;
}
.topbar__back {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-main);
font-size: 24px;
line-height: 1;
font-weight: 500;
}
.topbar-slot .topbar__action,
.topbar-slot .topbar__back {
min-height: 40px;
height: 40px;
color: var(--app-topbar-fg);
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
}
.topbar-slot .topbar__action {
min-width: 40px;
padding-block: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.topbar-slot .topbar__action svg,
.topbar-slot .topbar__back svg {
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
}
:root { --app-topbar-base-height: 56px; --app-topbar-title-size: 1.0625rem; --app-topbar-bg: var(--background); --app-topbar-fg: var(--text-primary); --app-topbar-gold: var(--text-primary); --app-topbar-blue-glow: transparent; --app-topbar-blue-glow-soft: transparent; }
.topbar { width: 100%; min-height: calc(56px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 0 0; display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 4px; background: var(--background); }
.topbar__left, .topbar__center, .topbar__right { display: flex; min-width: 0; align-items: center; }
.topbar__center { justify-content: center; }
.topbar__right { justify-content: flex-end; }
.topbar__title { margin: 0; min-width: 0; font-size: var(--app-topbar-title-size); line-height: 1.375rem; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar__left-label { font-size: .75rem; color: var(--text-secondary); }
.topbar__back, .topbar__action { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 6px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font-size: 22px; cursor: pointer; }
.topbar__center .app-topbar-title-action, .topbar__center .channels-top-title, .topbar__center .dm-head-filter-title { display: flex; flex-direction: column; justify-content: center; max-width: 100%; min-width: 0; min-height: 44px; padding: 0 4px; border: 0; background: transparent; color: var(--text-primary); font-size: var(--app-topbar-title-size); font-weight: 600; text-align: center; overflow: hidden; }
.topbar__back:hover, .topbar__action:hover, .topbar__center button:hover { color: var(--accent); background: var(--surface); }
.topbar button:active, .topbar button[aria-expanded='true'] { background: var(--surface-selected); }
.topbar button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.topbar button:disabled { color: var(--text-secondary); cursor: default; }
.topbar--profile .topbar__left { visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
.topbar__action,
.topbar__back { transition: none; }
}
/* Custom center content used by chat/channel/list screens still follows the same TopBar rhythm. */
.topbar-slot {
padding: 0 12px;
background: var(--app-topbar-bg);
}
.topbar__center .app-topbar-title-action,
.topbar__center .channels-top-title,
.topbar__center .dm-head-filter-title {
margin: 0;
min-width: 0;
max-width: 100%;
min-height: 40px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-main);
font-size: var(--app-topbar-title-size);
font-weight: 700;
line-height: 22px;
text-align: center;
}
.topbar__center .app-topbar-title-action::before,
.topbar__center .app-topbar-title-action::after,
.topbar__center .channels-top-title::before,
.topbar__center .channels-top-title::after,
.topbar__center .dm-head-filter-title::before,
.topbar__center .dm-head-filter-title::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.topbar-slot .topbar__center button,
.topbar-slot .topbar__center button:hover,
.topbar-slot .topbar__center button:focus,
.topbar-slot .topbar__center button:active,
.topbar-slot .topbar__center button:focus-visible {
color: #f7fbff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34);
backdrop-filter: none;
-webkit-backdrop-filter: none;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56));
}
.topbar-slot .topbar__center button::before,
.topbar-slot .topbar__center button::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
/* TopBar owns its button interaction states. */
.topbar-slot .topbar__action,
.topbar-slot .topbar__back {
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56));
}
.topbar-slot .topbar__action:active,
.topbar-slot .topbar__back:active,
.topbar-slot .topbar__center button:active {
background: rgba(0, 0, 0, 0.12);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
}
.topbar-slot .topbar__action:focus-visible,
.topbar-slot .topbar__back:focus-visible,
.topbar-slot .topbar__center button:focus-visible {
color: #ffffff;
outline: none;
filter: drop-shadow(0 0 5px rgba(110, 205, 255, 0.82)) drop-shadow(0 0 10px rgba(72, 145, 255, 0.42));
}
.topbar-slot .topbar__action[aria-expanded='true'],
.topbar-slot .topbar__action[data-open='true'],
.topbar-slot .topbar__center button[aria-expanded='true'],
.topbar-slot .topbar__center button[data-open='true'] {
background: rgba(0, 0, 0, 0.16);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
/* Migrated from legacy components.css during Stage 3 ownership split. */
.header-icon-svg {
display: block;
width: 1em;
height: 1em;
}
.header-icon-svg--phone,
.header-icon-svg--search {
width: 22px;
height: 22px;
}
.header-icon-svg { display: block; width: 22px; height: 22px; }
.topbar--root .topbar__center { justify-content: flex-start; }
.topbar--root .topbar__title { font-size: 1.5rem; line-height: 1.875rem; }
+7 -247
View File
@@ -1,247 +1,7 @@
/* Channel thread screen styles. */
.thread-node-card.is-rating {
border-color: transparent;
background:
linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)),
rgba(20, 25, 35, 0.55);
box-shadow: 0 0 38px rgba(181, 136, 42, 0.14);
}
.thread-node-stats.is-hidden {
opacity: 0;
max-height: 0;
overflow: hidden;
margin: 0;
}
.thread-node-heading {
color: #f1dcab;
font-size: 15px;
}
.thread-node-meta {
color: #aebddd;
font-size: 12px;
}
.thread-node-body {
color: #eef3ff;
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
border-radius: 10px;
padding: 8px 10px;
background: linear-gradient(170deg, rgba(22, 40, 73, 0.72), rgba(12, 25, 48, 0.78));
border: 1px solid rgba(116, 141, 193, 0.24);
}
.thread-node-stats {
color: #99acd6;
font-size: 12px;
}
.thread-node-views {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.58);
}
.thread-node-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.channels-screen--thread .thread-node-actions {
display: flex;
grid-template-columns: none;
}
.thread-node-level {
--depth: 0;
margin-left: calc(var(--depth) * 12px);
}
.channels-screen--thread .thread-node-card {
padding: 14px;
margin-bottom: 10px;
}
.thread-history-divider {
height: 0;
border-top: 2px solid rgba(255, 255, 255, 0.26);
margin: 6px 0 10px;
}
.thread-block--ancestors > .section-title {
color: #b9cbef;
}
.thread-block--ancestors {
border-color: rgba(141, 166, 214, 0.26);
background: linear-gradient(160deg, rgba(12, 28, 56, 0.73), rgba(7, 15, 32, 0.78));
}
.thread-block--focus > .section-title {
color: #f0d9a4;
}
.thread-block--focus {
border-color: transparent;
background: linear-gradient(160deg, rgba(33, 44, 72, 0.68), rgba(12, 20, 36, 0.8));
}
.thread-block--replies > .section-title {
color: #c8d6f5;
}
.thread-block--replies {
border-color: rgba(161, 186, 233, 0.24);
}
.thread-like-btn {
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
}
.thread-like-btn.is-long-press {
transform: scale(0.96);
filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28));
box-shadow: none;
}
.thread-open-btn {
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 430px) {
.thread-node-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 365px) {
.thread-node-actions {
grid-template-columns: 1fr;
}
}
.thread-node-card.is-own-new {
box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46);
}
@media (max-width: 420px) {
.thread-node-actions {
grid-template-columns: 1fr;
}
}
.thread-block {
gap: 8px;
padding: 8px;
background: rgba(20, 25, 35, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
border-color: rgba(255, 255, 255, 0.07);
}
.thread-summary {
color: #efd9a4;
background: rgba(20, 25, 35, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
border-color: rgba(255, 255, 255, 0.07);
}
.thread-node-card {
gap: 9px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
padding: 20px;
margin-bottom: 16px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(20, 25, 35, 0.55);
}
.thread-node-actions .secondary-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
cursor: pointer;
padding: 0;
min-height: 0;
white-space: nowrap;
}
.channels-screen--thread .channels-user-chip {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
margin-bottom: 0;
color: rgba(255, 255, 255, 0.35);
min-height: 0;
font-size: 12px;
}
.thread-block--replies > .section-title {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-weight: 500;
margin: 0 0 2px;
}
.channels-screen .thread-node-card,
.channels-screen .thread-block,
.channels-screen .thread-summary {
animation: breatheCard 8s ease-in-out infinite;
position: relative;
}
.channels-screen .thread-node-card:hover,
.channels-screen .thread-block:hover,
.channels-screen .thread-summary:hover {
transform: none;
}
/* Thread cards should stay fixed without breathing motion */
.channels-screen--thread .thread-node-card,
.channels-screen--thread .thread-block,
.channels-screen--thread .thread-summary {
animation: none;
}
/* Ветка сохраняет ширину текста на любой глубине. */
.thread-block { display: grid; gap: 0; min-width: 0; }
.thread-node-level { margin-left: 0; }
.thread-block--replies > .section-title { padding: 16px 0 8px; margin: 0; color: var(--text-secondary); font-size: .875rem; font-weight: 600; }
.thread-node-heading { color: var(--text-secondary); font-size: .8125rem; }
.thread-node-card .channel-message-avatar.avatar { width: 32px; height: 32px; min-width: 32px; min-height: 32px; }
.thread-history-divider { border-top: 1px solid var(--border-subtle); margin: 8px 0; }
File diff suppressed because it is too large Load Diff
+44 -431
View File
@@ -1,438 +1,51 @@
/* Styles shared by Channel and Thread rendering. */
.attachment-trigger-btn {
justify-self: start;
min-width: 3.25rem;
padding-inline: 0.8rem;
}
.draft-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.draft-attachment-chip {
border-radius: 999px;
cursor: pointer;
font-size: 0.82rem;
max-width: 100%;
overflow: hidden;
padding: 0.4rem 0.65rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-danger-action {
width: 100%;
}
.channels-screen--channel .channel-message-card {
margin-bottom: 6px;
}
.channels-screen--channel .channel-message-card.is-diary-entry {
width: min(100%, 28rem);
max-width: min(84vw, 28rem);
padding: 16px 16px 15px;
justify-self: center;
align-self: center;
}
.channel-message-author-tile {
appearance: none;
-webkit-appearance: none;
display: flex;
align-items: center;
gap: 12px;
flex: 1 1 auto;
width: auto;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
text-align: left;
cursor: pointer;
min-height: 0;
}
.channel-message-avatar {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: #f4f6ff;
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
}
.channel-message-author {
/* Общий контракт публикаций и ответов. */
.channels-screen .channel-message-card {
display: grid;
gap: 4px;
min-width: 0;
}
.channel-message-head-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.channel-message-title {
font-size: 15px;
color: #f5f8ff;
}
.channel-message-kind-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 1.2;
}
.channel-message-kind-badge--rating {
color: #ffe8b0;
background: rgba(124, 92, 28, 0.36);
border: 1px solid transparent;
}
.channel-message-body {
color: #ffffff;
line-height: 1.5;
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
min-width: 0;
margin: 0;
padding: 0;
background: transparent;
border: 0;
}
.channel-message-card--deleted-compact {
gap: 7px;
padding-top: 9px;
padding-bottom: 9px;
}
.deleted-message-pill {
appearance: none;
width: 100%;
border-radius: 12px;
font-size: 13px;
line-height: 1.2;
text-align: left;
padding: 8px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-message-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
}
.channel-message-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.channel-message-actions::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.channel-action-item {
appearance: none;
padding: 16px 0 8px;
border: 0;
border-bottom: 1px solid var(--border-subtle);
border-radius: 0;
background: transparent;
position: relative;
min-height: 0;
padding: 0;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 24px;
font-size: 12px;
cursor: pointer;
transition: color 0.18s ease, text-shadow 0.18s ease, transform 0.18s ease;
white-space: nowrap;
}
.channel-action-item:disabled {
opacity: 0.56;
cursor: not-allowed;
}
.channel-action-icon {
font-size: 14px;
line-height: 1;
}
.channel-action-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.message-edited-marker {
appearance: none;
border: none;
background: transparent;
font-size: 11px;
line-height: 1;
padding: 0;
margin-left: 6px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.channel-action-counter {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
opacity: 0;
max-width: 0;
overflow: hidden;
transform: translateX(-3px);
transition: opacity 0.2s ease, max-width 0.2s ease, transform 0.2s ease, margin-left 0.2s ease;
}
.blockchain-details-card {
max-width: min(620px, calc(100vw - 28px));
}
.blockchain-details-grid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px 12px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
}
.blockchain-details-grid span {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
}
.blockchain-details-grid code,
.blockchain-details-grid strong {
min-width: 0;
color: rgba(255, 244, 210, 0.96);
font-size: 12px;
overflow-wrap: anywhere;
}
.blockchain-raw-block {
max-height: 280px;
overflow: auto;
padding: 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.32);
color: rgba(255, 255, 255, 0.82);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.channel-header-route-btn {
position: static;
min-height: 40px;
max-width: min(72vw, 100%);
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
border-radius: 10px;
text-align: center;
transform: none;
}
.channel-header-route-btn:hover,
.channel-header-route-btn:focus-visible,
.channel-header-route-btn:active,
.channel-header-route-btn.is-springing {
transform: none;
}
@media (max-width: 430px) {
.channel-message-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 365px) {
.channel-message-actions {
grid-template-columns: 1fr;
}
}
.author-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
width: 100%;
}
.author-line-main {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.channel-message-type-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 82px;
min-height: 28px;
padding: 6px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer;
}
.channel-message-type-button.is-static,
.channel-message-type-button:disabled {
opacity: 1;
cursor: default;
}
.author-line-login {
font-weight: 700;
color: #f5f8ff;
}
.author-line-num {
font-weight: 400;
color: rgba(255, 255, 255, 0.44);
}
@media (max-width: 420px) {
.channel-message-actions {
grid-template-columns: 1fr;
}
}
.channel-message-actions,
.thread-node-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* 2) Static controls with energy + glass glare (no levitation) */
.channels-screen--list .channels-tab-btn,
.channels-screen--list .channels-bottom-action,
.channels-screen .channel-main-action,
.channels-screen .channel-back-btn,
.channels-screen .channel-head-actions .secondary-btn,
.channels-screen .channel-action-item {
position: relative;
overflow: hidden;
transition: box-shadow 0.75s ease-out, color 0.28s ease, border-color 0.28s ease, background 0.28s ease;
box-shadow: none;
animation: none;
}
.channels-screen--list .channels-tab-btn:hover,
.channels-screen--list .channels-bottom-action:hover,
.channels-screen .channel-main-action:hover,
.channels-screen .channel-back-btn:hover,
.channels-screen .channel-head-actions .secondary-btn:hover,
.channels-screen .channel-action-item:hover {
transform: none;
}
.channels-screen::before,
.channels-screen.channels-screen--channel::before {
background: transparent;
}
.channel-message-head-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
.channel-message-author-tile { display: flex; flex: 1; gap: 12px; align-items: center; min-width: 0; min-height: 44px; padding: 0; text-align: left; cursor: pointer; }
.channel-message-author { display: grid; min-width: 0; gap: 2px; }
.channel-message-avatar.avatar { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
.channel-message-title, .author-line-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; min-width: 0; }
.author-line-login { font-size: .875rem; line-height: 1.25rem; font-weight: 600; color: var(--text-primary); overflow-wrap: anywhere; }
.author-line-num, .channel-message-time { font-size: .75rem; line-height: 1.125rem; color: var(--text-secondary); }
.channel-message-body { margin: 0; font-size: .9375rem; line-height: 1.4375rem; color: var(--text-primary); white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
.channel-message-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.channel-action-item { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; min-height: 44px; padding: 8px; border-radius: 12px; color: var(--text-secondary); cursor: pointer; font-size: .75rem; }
.channel-action-item:hover { color: var(--text-primary); background: var(--surface); }
.channel-action-item.is-liked { color: var(--reaction-active); }
.channel-action-item:disabled { opacity: .5; cursor: wait; }
.channel-action-icon { display: inline-flex; font-size: 22px; line-height: 1; }
.channel-action-icon svg { width: 22px; height: 22px; }
.channel-action-counter { color: inherit; font-size: .75rem; }
.channel-action-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.channel-action-reply, .thread-reply-btn { margin-left: auto; color: var(--accent); }
.channel-action-reply .channel-action-label, .thread-reply-btn .channel-action-label { position: static; width: auto; height: auto; clip: auto; }
.channel-action-reply .channel-action-icon, .channel-action-reply .channel-action-counter, .thread-reply-btn .channel-action-icon, .thread-reply-btn .channel-action-counter { display: none; }
.channel-message-more { width: 44px; height: 44px; margin-left: auto; font-size: 24px; color: var(--text-secondary); }
.channel-message-kind-badge, .channel-message-type-button { min-height: 28px; padding: 6px 10px; border-radius: 8px; color: var(--accent); background: var(--surface-selected); font-size: .75rem; }
.channel-message-type-button:not(:disabled) { min-height: 44px; cursor: pointer; }
.message-edited-marker { font-size: .75rem; color: var(--text-secondary); }
.deleted-message-pill { min-height: 44px; text-align: left; color: var(--text-secondary); padding: 8px 0; }
.channel-header-title, .channel-header-owner { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.channel-header-owner { color: var(--text-secondary); font-size: .75rem; line-height: 1rem; font-weight: 400; }
.channel-composer { display: flex; gap: 8px; padding: 8px 0; background: var(--background); }
.channel-composer > button { width: 100%; min-height: 44px; border-radius: 12px; color: var(--on-accent); background: var(--accent); }
.blockchain-details-grid { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 8px; font-size: .75rem; }
.blockchain-details-grid code, .blockchain-raw-block { overflow-wrap: anywhere; white-space: pre-wrap; }
.blockchain-raw-block { max-height: 40vh; overflow: auto; padding: 12px; background: var(--background); }
.draft-attachments { display: flex; flex-wrap: wrap; gap: 8px; }
.draft-attachment-chip { max-width: 100%; overflow-wrap: anywhere; }
.modal-danger-action { width: 100%; color: var(--danger); }
.thread-reply-context { color: var(--text-secondary); font-size: .75rem; line-height: 1.125rem; border-left: 2px solid var(--border-control); padding-left: 10px; overflow-wrap: anywhere; }
@media (prefers-reduced-motion: reduce) { .channels-screen *, .channel-composer * { animation: none; scroll-behavior: auto; transition: none; } }
File diff suppressed because it is too large Load Diff
-281
View File
@@ -254,287 +254,6 @@
}
.message-attachments {
display: grid;
gap: 0.5rem;
}
.message-attachment-carousel {
gap: 0.35rem;
margin: 0.5rem 0 0.4rem;
max-width: min(100%, 34rem);
}
.message-attachment-carousel-viewport {
align-items: center;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.05), rgba(20, 184, 166, 0.08));
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 1.15rem;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
touch-action: pan-y;
}
.message-attachment-carousel-viewport.has-landscape-media {
align-items: start;
min-height: 0;
}
.message-attachment-slide {
align-items: stretch;
display: grid;
min-height: 12rem;
}
.message-attachment-slide.has-landscape-media {
min-height: 0;
}
.message-attachment-arrow {
align-items: center;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
font-size: 1.85rem;
height: 2.35rem;
justify-content: center;
line-height: 1;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.35rem;
z-index: 2;
}
.message-attachment-arrow[hidden] {
display: none;
}
.message-attachment-arrow:disabled {
cursor: default;
opacity: 0.28;
}
.message-attachment-arrow--prev {
left: 0.55rem;
}
.message-attachment-arrow--next {
right: 0.55rem;
}
.message-attachment-counter {
color: #64748b;
font-size: 0.78rem;
text-align: center;
}
.message-attachment-media-frame {
align-items: center;
background: #0f172a;
cursor: zoom-in;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
}
.message-attachment-media-frame.is-landscape {
min-height: 0;
}
.message-attachment-media {
display: block;
height: min(52vh, 20rem);
max-height: 20rem;
object-fit: contain;
width: 100%;
}
.message-attachment-media.is-landscape {
height: auto;
}
.message-attachment-play {
align-items: center;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
color: #fff;
display: inline-flex;
font-size: 1.7rem;
height: 4rem;
justify-content: center;
left: 50%;
padding-left: 0.18rem;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4rem;
}
.message-attachment-download {
background: rgba(20, 184, 166, 0.95);
border-radius: 999px;
color: #fff;
font-size: 0.82rem;
font-weight: 700;
padding: 0.45rem 0.75rem;
position: relative;
text-decoration: none;
width: max-content;
z-index: 3;
}
.message-attachment-media-frame > .message-attachment-download {
bottom: 0.75rem;
position: absolute;
right: 0.75rem;
}
.message-attachment-file-card {
align-items: center;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.94));
border: 1px solid rgba(20, 184, 166, 0.28);
border-radius: 0.8rem;
color: #f8fafc;
cursor: pointer;
display: grid;
gap: 0.55rem;
grid-template-columns: auto minmax(0, 1fr);
min-height: 3.25rem;
padding: 0.55rem 0.7rem;
}
.message-attachment-file-ext {
align-items: center;
background: rgba(20, 184, 166, 0.95);
border-radius: 999px;
color: #fff;
display: inline-flex;
font-size: 0.72rem;
font-weight: 800;
height: 1.7rem;
justify-content: center;
min-width: 3.25rem;
padding: 0 0.55rem;
}
.message-attachment-file-main {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.message-attachment-file-main .message-attachment-meta {
color: #cbd5e1;
}
.message-attachment-unavailable {
align-items: center;
background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 247, 237, 0.96));
color: #7f1d1d;
display: flex;
font-weight: 700;
justify-content: center;
min-height: 12rem;
padding: 1.25rem;
text-align: center;
}
.message-attachment-card {
align-items: center;
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: 1rem;
color: #0f172a;
display: flex;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
text-decoration: none;
}
.message-attachment-icon {
align-items: center;
background: #0f766e;
border-radius: 0.8rem;
color: #fff;
display: inline-flex;
flex: 0 0 auto;
height: 2.1rem;
justify-content: center;
width: 2.1rem;
}
.message-attachment-main {
display: grid;
min-width: 0;
}
.message-attachment-name {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment-meta {
color: #64748b;
font-size: 0.78rem;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.message-attachment-carousel {
max-width: 100%;
}
.message-attachment-carousel-viewport,
.message-attachment-slide,
.message-attachment-media-frame,
.message-attachment-unavailable {
min-height: 10rem;
}
.message-attachment-media {
height: min(42vh, 16rem);
}
.message-attachment-file-card {
grid-template-columns: auto minmax(0, 1fr);
}
.message-attachment-file-card .message-attachment-download {
grid-column: 1 / -1;
}
}
.dm-chat-wrap {
+48 -20
View File
@@ -1,27 +1,59 @@
:root {
--bg-0: #050c1a;
--bg-1: #0a1630;
--bg-2: #132346;
--card: #162646;
--card-soft: #1a2f55;
--line: #2f4777;
--text: #edf2ff;
--text-muted: #9eb0d8;
--accent: #d9b56f;
--accent-soft: rgba(217, 181, 111, 0.18);
--danger: #ff718f;
--ok: #84f4a1;
--background: #101b20;
--surface: #17272d;
--surface-selected: #213b3a;
--text-primary: #e8f2f1;
--text-secondary: #98adaf;
--border-subtle: #2a3b40;
--border-control: #71888a;
--accent: #94e1ce;
--on-accent: #102c27;
--focus-ring: #94e1ce;
--reaction-active: #f29aab;
--danger: #ffaba8;
--success: #94e1ce;
--warning: #e6c184;
--scrim: #00000099;
--bg-0: var(--background);
--bg-1: var(--surface);
--bg-2: var(--surface-selected);
--card: var(--surface);
--card-soft: var(--surface-selected);
--line: var(--border-subtle);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--ok: var(--success);
/* Единый цвет основных действий: стартовые/регистрационные кнопки,
* настройки и scroll-down. */
--shine-action-blue: #1248a3;
--shine-action-blue-hover: #1b62cf;
--shine-action-blue-pressed: #0d3577;
--shine-action-blue-rgb: 18, 72, 163;
--radius-lg: 18px;
--radius-lg: 14px;
--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;
--shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
--font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme='light'] {
--background: #faf7f0;
--surface: #fffdf8;
--surface-selected: #efe6d6;
--text-primary: #302c25;
--text-secondary: #706658;
--border-subtle: #e6dfd1;
--border-control: #978b79;
--accent: #93511e;
--on-accent: #fffaf3;
--focus-ring: #93511e;
--reaction-active: #a43350;
--danger: #b13135;
--success: #346a48;
--warning: #845b14;
--scrim: #302c2566;
--shadow: 0 16px 42px rgba(65, 49, 28, 0.14);
}
* {
@@ -37,10 +69,7 @@ body {
min-height: 100%;
overflow: hidden;
overscroll-behavior: none;
background:
radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%),
radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%),
linear-gradient(180deg, #050b18, #030812 70%) fixed;
background: var(--background);
color: var(--text);
font-family: var(--font-main);
}
@@ -72,4 +101,3 @@ a {