SHA256
Добавить тестовые каналы и Arweave-синхронизацию
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Static sites
|
||||
|
||||
Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.
|
||||
|
||||
Все содержимое этой папки деплоится вместе с `shine-UI`, потому что общий UI deploy синхронизирует весь каталог `shine-UI/` в Caddy web root. На текущей схеме отдельная перенастройка Caddy для каждого нового сайта не нужна: Caddy сначала отдает существующие файлы из UI root, а уже потом делает fallback на основной `index.html`.
|
||||
|
||||
## URL
|
||||
|
||||
Если UI задеплоен на `https://t2.shineup.me`, то эта папка доступна как:
|
||||
|
||||
```text
|
||||
https://t2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
Текущие подпапки:
|
||||
|
||||
```text
|
||||
/static-sites/design-examples/channels-v1/
|
||||
/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Для production домен меняется на нужный хост, например:
|
||||
|
||||
```text
|
||||
https://shineup.me/static-sites/
|
||||
https://server2.shineup.me/static-sites/
|
||||
```
|
||||
|
||||
## Как добавлять новый временный сайт
|
||||
|
||||
1. Создать подпапку внутри `shine-UI/static-sites/`.
|
||||
2. Положить туда `index.html` и связанные файлы.
|
||||
3. Выполнить обычный UI deploy нужного контура через `deploy/scripts/*_ui.sh`.
|
||||
|
||||
После этого сайт будет доступен по URL:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/<folder>/
|
||||
```
|
||||
|
||||
## Arweave viewer
|
||||
|
||||
Постоянная папка для viewer:
|
||||
|
||||
```text
|
||||
shine-UI/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
Когда появляется новая версия viewer, можно заменить содержимое этой папки новой версией и выполнить обычный UI deploy. URL останется прежним:
|
||||
|
||||
```text
|
||||
https://<host>/static-sites/arweave-viewer/
|
||||
```
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1
|
||||
|
||||
Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`.
|
||||
|
||||
Основной файл: `index.html`.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
Через логин (ключ берётся из Solana PDA):
|
||||
|
||||
```text
|
||||
index.html?login=aidarkc&channel=books
|
||||
```
|
||||
|
||||
Напрямую через blockchain public key, без Solana:
|
||||
|
||||
```text
|
||||
index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
```
|
||||
|
||||
Дополнительные параметры:
|
||||
|
||||
```text
|
||||
&arweaveGateway=https://arweave.net
|
||||
&solanaRpc=https://api.mainnet-beta.solana.com
|
||||
```
|
||||
|
||||
`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков.
|
||||
|
||||
## Что делает viewer
|
||||
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<login>` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`.
|
||||
2. Если передан `key`, использует его напрямую и не обращается к Solana.
|
||||
3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=<channel>`.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer.
|
||||
|
||||
## Важно
|
||||
|
||||
При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально.
|
||||
|
||||
Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`.
|
||||
|
||||
|
||||
## Проверка данных (v3.3)
|
||||
Нижняя строка показывает краткий итог доступных проверок. Нажатие на неё или `⋮ → Проверка данных` открывает отдельный экран:
|
||||
- локально вычисленный SHA-256 каждого SHiNE Frame;
|
||||
- `prevHash` общей цепочки, когда предыдущий глобальный блок загружен;
|
||||
- `prevLineHash` канальной цепочки, когда указанный предыдущий блок загружен;
|
||||
- наличие DataItem в выборке Arweave owner соответствующего blockchain key;
|
||||
- разрывы «нет данных» отдельно от доказанных несовпадений;
|
||||
- честный статус ANS-104 signature: gateway payload/GraphQL не содержит сырой подписи, поэтому она не помечается как локально перепроверенная.
|
||||
|
||||
`○ нет данных` не является ошибкой. `⚠` появляется только при фактическом несовпадении доступных хэшей.
|
||||
@@ -0,0 +1,26 @@
|
||||
# SHiNE Arweave Viewer v3
|
||||
|
||||
Viewer нового per-action Arweave формата SHiNE.
|
||||
|
||||
## URL
|
||||
- `?login=<login>&channel=<channel>` — получает blockchain key через Solana PDA, затем читает Arweave.
|
||||
- `?key=<base58>&channel=<channel>` — Arweave запускается напрямую; Solana не является блокирующей зависимостью.
|
||||
- без параметров — стартовая форма (логин или blockchain key + необязательный канал).
|
||||
|
||||
## v3
|
||||
- переключатель `Канал / Блоки`;
|
||||
- рекурсивные комментарии скрыты до раскрытия, на любой глубине;
|
||||
- light/dark тема;
|
||||
- меню `⋮`: кэш, Solana RPC, Arweave gateway;
|
||||
- выключение кэша удаляет сохранённые данные каналов;
|
||||
- одна нижняя status bar, по нажатию — подробный журнал;
|
||||
- GraphQL читает от новых Arweave записей к старым (`HEIGHT_DESC`);
|
||||
- до 6 параллельных загрузок Frame;
|
||||
- отображение известных типов и raw-представление любых неизвестных блоков в режиме `Блоки`;
|
||||
- лайки намеренно не отображаются.
|
||||
|
||||
Кэш использует browser localStorage и сериализует BigInt-поля Frame явно. Тема/RPC/gateway являются настройками и не удаляются при отключении кэша.
|
||||
|
||||
## V3.1 — streaming reverse loading
|
||||
|
||||
Arweave history is now processed page-by-page in `HEIGHT_DESC` order. The first GraphQL page (up to 100 DataItems) is downloaded, parsed and rendered immediately. Older pages are then fetched and merged in the background. The bottom status line shows the currently available block range while history expands toward the beginning. Each completed page is also persisted when cache is enabled.
|
||||
@@ -0,0 +1,11 @@
|
||||
# SHiNE Solana Arweave Viewer
|
||||
|
||||
Основной рабочий файл:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Подробное описание работы, параметров и готовых ссылок:
|
||||
|
||||
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||
|
||||
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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,98 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SHiNE static sites</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1ec;
|
||||
--panel: #fffaf3;
|
||||
--ink: #171512;
|
||||
--muted: #6f675e;
|
||||
--line: rgba(52, 44, 35, 0.14);
|
||||
--accent: #087a68;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(30px, 5vw, 52px);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
border-color: rgba(8, 122, 104, 0.45);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>SHiNE static sites</h1>
|
||||
<p>Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.</p>
|
||||
|
||||
<div class="list">
|
||||
<a href="./design-examples/channels-v1/">
|
||||
<strong>Channels design v1</strong>
|
||||
<span>Дизайн-пример экранов каналов.</span>
|
||||
</a>
|
||||
<a href="./arweave-viewer/">
|
||||
<strong>Arweave viewer</strong>
|
||||
<span>Постоянная папка для текущей версии SHiNE Arweave Viewer.</span>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user