SHA256
Дизайн Артёма
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.21
|
||||
client.version=1.12.22
|
||||
server.version=1.10.8
|
||||
|
||||
@@ -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 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения |
|
||||
@@ -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 не затрагивался.
|
||||
@@ -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-макеты для обсуждения, не проверенная на устройствах реализация приложения.
|
||||
@@ -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-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет.
|
||||
- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания.
|
||||
@@ -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>
|
||||
@@ -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; } } }
|
||||
@@ -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 => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[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();
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user