Initial commit: ASPEKTER — визуальный конструктор email-рассылок
- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем - email-gen: Node.js рендерер Pug→HTML через email-templates + Juice - email-gen-api: HTTP сервер рендеринга (порт 8787) - coin-scout: сервис подбора монет из фидов - Docker Compose для dev/prod - Nginx конфиг с SSL для app.aspekter.ru
This commit is contained in:
2
.env.prod.example
Normal file
2
.env.prod.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Host port for nginx in production compose
|
||||
HTTP_PORT=80
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
z51-pug-builder/data/
|
||||
z51-pug-builder/data-dev/
|
||||
data/
|
||||
*.log
|
||||
.env
|
||||
z51-pug-builder/data/uploads/
|
||||
coin-scout/data/
|
||||
213
Block.pug
Normal file
213
Block.pug
Normal file
@@ -0,0 +1,213 @@
|
||||
//Заголовок зеленый
|
||||
tr
|
||||
td.paddingWrapperBig
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(align="center")
|
||||
span.text.smallHeader.bold.greenText Конитива, герой!
|
||||
|
||||
|
||||
//Заголовок серый
|
||||
tr
|
||||
td.paddingWrapperBig
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(align="center")
|
||||
span.text.smallHeader.bold.groceryText Конитива, герой!
|
||||
|
||||
|
||||
//Текст
|
||||
tr
|
||||
td.paddingWrapperBig
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td
|
||||
span.text.groceryText Пока одни вспоминают старую «Якудзу-3» с ее бесконечными блоками и симулятором няньки, студия Ryu Ga Gotoku Studio готовит полноценную революцию!
|
||||
|
||||
//Доп. текст
|
||||
+spacerLine(20)
|
||||
tr
|
||||
td
|
||||
span.text.groceryText <span style="font-weight: 700;">Минимальные</span> 1080p / 30 FPS (с FSR)<br>Проц: Intel i3-8100 / AMD Ryzen 3 2300X<br>Видяха: NVIDIA GTX 1650 / AMD RX 6400<br>Оперативочка: 8 ГБ<br>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//Отступ 20
|
||||
+spacerLine(20)
|
||||
|
||||
|
||||
//Отступ 40
|
||||
+spacerLine(40)
|
||||
|
||||
//3 товара в ряд
|
||||
+products3inRow({
|
||||
'144839': {
|
||||
imageSrc: '',
|
||||
name: 'BAD BUNNY',
|
||||
category: 'Осторожно, этот кролик плохой)',
|
||||
},
|
||||
'142672': {
|
||||
imageSrc: '',
|
||||
name: 'MONARCH',
|
||||
category: 'Ролекс среди кресел',
|
||||
},
|
||||
'140228': {
|
||||
imageSrc: '',
|
||||
name: 'Kitty Meow',
|
||||
category: 'Кошечка делает мур-р-р!',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
//Разделитель
|
||||
+dividerZ(525, 2)
|
||||
+spacerLine(40)
|
||||
|
||||
//Банер
|
||||
tr
|
||||
td(align="center")
|
||||
a(href="https://z51.ru" target="_blank")
|
||||
img(src="https://z51.ru/upload/email/newsletter-2026/20-01-2026/1.jpg" alt="pic" style="display: block" width="600")
|
||||
|
||||
|
||||
|
||||
//Кнопка
|
||||
tr
|
||||
td(align="center").paddingWrapper
|
||||
+buttonRounded("Смотреть топовые ПК", "https://z51.ru/catalog/gaming-pc/", 525, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
|
||||
|
||||
//Две кнопки
|
||||
tr
|
||||
td.paddingWrapper
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(width="250")
|
||||
+defaultTable("250")
|
||||
tr
|
||||
td(align="center")
|
||||
+buttonRounded("Игровые кресла", "https://z51.ru/catalog/kresla/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
|
||||
+tdFixed(36)
|
||||
td(width="250")
|
||||
+defaultTable("250")
|
||||
tr
|
||||
td(align="center")
|
||||
+buttonRounded("Эргономичные кресла", "https://z51.ru/catalog/ergonomic-office-chairs/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905", 4).bold.text
|
||||
|
||||
|
||||
//Блок преимуществ
|
||||
tr
|
||||
td.paddingWrapperBig
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(align="center")
|
||||
span.text.smallHeader.bold.greenText Почему выбирают товары у Баззи?
|
||||
|
||||
+spacerLine(40)
|
||||
|
||||
tr
|
||||
td.paddingWrapper
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(width="250" valign="top")
|
||||
+defaultTable("250")
|
||||
//Unordered List
|
||||
tr
|
||||
td
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
|
||||
+tdFixed(10)
|
||||
td
|
||||
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Официальный магазин</span><br />В наличии всё самое вкусное от ZONE 51 — кресла, столы, периферия и аксессуары
|
||||
+spacerLine(20)
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
|
||||
+tdFixed(10)
|
||||
td
|
||||
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Первоклассные и надежные продукты</span><br />Из качественных, инопланетных и <span style="text-decoration: line-through;">вроде как</span> безопасных материалов для себя, родных и друзей. Не понравилось? Можешь вернуть в течение 28 дней с даты приобретения
|
||||
+tdFixed(36)
|
||||
td(width="250" valign="top")
|
||||
+defaultTable("250")
|
||||
tr
|
||||
td
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
|
||||
+tdFixed(10)
|
||||
td
|
||||
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Новинки и эксклюзивы</span><br />Я постоянно потею над новыми товарами, которые можно приобрести только здесь
|
||||
+spacerLine(20)
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
|
||||
+tdFixed(10)
|
||||
td
|
||||
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Клиенто-ориентированность</span><br />Даю до 3 лет гарантии на свой товар +1 год за покупку в фирменном магазине ZONE 51 (онлайн и офлайн), а человеки у трубки помогут быстро обкашлять любые вопросы
|
||||
+spacerLine(40)
|
||||
|
||||
tr
|
||||
td(align="center").paddingWrapper
|
||||
+buttonRounded("Залетай к нам!", "https://z51.ru/", 300, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
|
||||
//Нумерованный список
|
||||
+spacerLine(20)
|
||||
tr
|
||||
td.paddingWrapper
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/1.png" alt="" width="14")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего мира
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/2.png" alt="" width="14")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего мира
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/3.png" alt="" width="14")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/4.png" alt="" width="14")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего
|
||||
//Маркированный список
|
||||
+spacerLine(25)
|
||||
tr
|
||||
td.paddingWrapper
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего мира
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего мира
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего
|
||||
tr
|
||||
+tdFixed(12, "center", "top").markerPadding
|
||||
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
|
||||
+tdFixed(16)
|
||||
td
|
||||
span.text БРЕНД ZONE 51 – результат работы профессионалов со всего
|
||||
117
DOCKER-DEPLOY.md
Normal file
117
DOCKER-DEPLOY.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Docker deploy: z51-pug-builder + email-gen
|
||||
|
||||
## Что есть
|
||||
|
||||
- `docker-compose.yml` — локальный/дев запуск (builder на `localhost:5173`)
|
||||
- `docker-compose.prod.yml` — прод запуск через nginx
|
||||
|
||||
Сервисы:
|
||||
- `builder` — Svelte-конструктор + локальный API (`/api/*`)
|
||||
- `email-gen-api` — мост к `email-gen` (рендер PUG -> HTML)
|
||||
- `nginx` (только в prod compose) — входная точка на 80 порту
|
||||
|
||||
---
|
||||
|
||||
## 1) Локальный запуск (dev)
|
||||
|
||||
```bash
|
||||
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Открыть: `http://localhost:5173`
|
||||
|
||||
Остановить:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) Прод запуск (nginx + docker)
|
||||
|
||||
1. Подготовь env:
|
||||
```bash
|
||||
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
|
||||
cp .env.prod.example .env
|
||||
```
|
||||
|
||||
2. Запусти:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
3. Проверка:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
Открыть: `http://<VPS_IP>` (или домен, если DNS уже настроен).
|
||||
|
||||
Остановить:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Как работает генерация HTML
|
||||
|
||||
1. В конструкторе собирается PUG.
|
||||
2. Нажимаешь `Превью -> Обновить`.
|
||||
3. Builder вызывает `POST /api/project/:name/render-email`.
|
||||
4. Запрос уходит в `email-gen-api`.
|
||||
5. `email-gen-api`:
|
||||
- пишет PUG в `email-gen/emails/<project>/letters/let.pug`
|
||||
- запускает `gulp pug --project <project>`
|
||||
- читает `email-gen/public/index.html`
|
||||
- возвращает HTML обратно в конструктор.
|
||||
|
||||
Важно: в `Настройки -> Текущий проект` заполняй поле `Папка проекта в email-gen` (например `numizmat`).
|
||||
|
||||
---
|
||||
|
||||
## 4) Обновление email-gen без ручной пересборки всего
|
||||
|
||||
Скрипт:
|
||||
```bash
|
||||
./deploy/scripts/update-email-gen.sh
|
||||
```
|
||||
|
||||
Или с веткой:
|
||||
```bash
|
||||
./deploy/scripts/update-email-gen.sh main
|
||||
```
|
||||
|
||||
Что делает скрипт:
|
||||
- `git fetch`/`git pull --ff-only` в `email-gen`
|
||||
- пересобирает и перезапускает только `email-gen-api` контейнер
|
||||
|
||||
Это удобно, если `email-gen` обновляется через git и перезаписывается.
|
||||
|
||||
---
|
||||
|
||||
## 5) Логи
|
||||
|
||||
Prod:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml logs -f nginx
|
||||
docker compose -f docker-compose.prod.yml logs -f builder
|
||||
docker compose -f docker-compose.prod.yml logs -f email-gen-api
|
||||
```
|
||||
|
||||
Dev:
|
||||
```bash
|
||||
docker compose logs -f builder
|
||||
docker compose logs -f email-gen-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Данные
|
||||
|
||||
- Данные конструктора: `z51-pug-builder/data`
|
||||
- Репозиторий генератора: `email-gen` (bind mount в контейнер)
|
||||
|
||||
Оба каталога остаются на хосте и не теряются при пересоздании контейнеров.
|
||||
47
Untitled-1
Normal file
47
Untitled-1
Normal file
@@ -0,0 +1,47 @@
|
||||
cd /Users/sergeyzotov/Documents/GENERATOR_Z51/z51-pug-builder
|
||||
|
||||
npm run dev
|
||||
|
||||
//Текст
|
||||
tr
|
||||
td.paddingWrapperBig
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td
|
||||
span.text.groceryText Запоминай дату: <span style="font-weight: 700;">13 февраля 2026 года. </span>
|
||||
+spacerLine(20)
|
||||
tr
|
||||
td
|
||||
span.text.groceryText В этот день ты точно поменяешь
|
||||
+spacerLine(20)
|
||||
tr
|
||||
td
|
||||
span.text.groceryText Говорят, лучший способ пережить
|
||||
+spacerLine(20)
|
||||
tr
|
||||
td
|
||||
span.text.groceryText Но сначала в
|
||||
|
||||
|
||||
отлично, только сделай кнопку пиктограммой в виде папки, и перенеси рядом с кнопкой сохранения. и добавь всем кнопкам и полям описание которое показывается при наведении мыши
|
||||
|
||||
|
||||
|
||||
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
|
||||
docker compose up -d --build --force-recreate builder
|
||||
|
||||
|
||||
кб
|
||||
https://email-images.mindbox.ru/numizmat/1371ccef-c936-48cd-b0b1-410beb7f04b5/38e24939-55d1-49cf-8932-561a78176448.jpg
|
||||
|
||||
РУ
|
||||
https://email-images.mindbox.ru/numizmat/1371ccef-c936-48cd-b0b1-410beb7f04b5/6dfa2b76-6f67-4be3-8ecc-ac88e5ff0a03.png
|
||||
|
||||
|
||||
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
|
||||
|
||||
# stable
|
||||
docker compose -p z51_stable up -d --build
|
||||
|
||||
# dev
|
||||
docker compose -p z51_dev -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
BIN
aspekter.zip
Normal file
BIN
aspekter.zip
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/._editor-svelte
Executable file
BIN
aspekter_ref/__MACOSX/._editor-svelte
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._.DS_Store
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._.DS_Store
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._.vscode
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._.vscode
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._README.md
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._README.md
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._dist
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._dist
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._html.pug
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._html.pug
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._index.html
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._index.html
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._node_modules
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._node_modules
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._package-lock.json
generated
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._package-lock.json
generated
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._package.json
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._package.json
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._public
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._public
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._server
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._server
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._src
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/._src
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._svelte.config.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._svelte.config.js
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/._vite.config.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/._vite.config.js
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/.vscode/._settings.json
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/.vscode/._settings.json
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._assets
vendored
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._assets
vendored
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._favicon.svg
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._favicon.svg
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._index.html
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._index.html
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._readme.html
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/._readme.html
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/assets/._index-BUFzIg5U.js
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/assets/._index-BUFzIg5U.js
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/assets/._index-CvxZHvQj.css
vendored
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/dist/assets/._index-CvxZHvQj.css
vendored
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/public/._favicon.svg
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/public/._favicon.svg
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/public/._readme.html
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/public/._readme.html
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/server/._index.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/server/._index.js
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._.DS_Store
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._.DS_Store
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._App.svelte
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._App.svelte
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._app.css
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._app.css
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._components
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._components
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._lib
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._lib
Executable file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._main.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._main.js
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._store.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/._store.js
Normal file
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/components/._.DS_Store
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/components/._.DS_Store
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/components/._blocks
Executable file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/components/._blocks
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
aspekter_ref/__MACOSX/editor-svelte/src/lib/._pug.js
Normal file
BIN
aspekter_ref/__MACOSX/editor-svelte/src/lib/._pug.js
Normal file
Binary file not shown.
3
aspekter_ref/editor-svelte/.vscode/settings.json
vendored
Normal file
3
aspekter_ref/editor-svelte/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
74
aspekter_ref/editor-svelte/README.md
Normal file
74
aspekter_ref/editor-svelte/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Конструктор писем VipAvenue (Svelte)
|
||||
|
||||
Подробная инструкция по использованию редактора для сборки Pug-кода писем.
|
||||
|
||||
## Запуск проекта
|
||||
1. Установите зависимости: `npm install`
|
||||
2. Запустите dev-сервер: `npm run dev`
|
||||
3. Откройте ссылку из терминала (обычно `http://localhost:5173`)
|
||||
|
||||
## Сохранение в email-gen
|
||||
Для записи `let.pug` и `html.pug` нужен локальный сервер. Автосохранение работает только для `html.pug`, а `let.pug` сохраняется кнопкой «Сохранить».
|
||||
|
||||
### Сборка и запуск
|
||||
1. Соберите фронт: `npm run build`
|
||||
2. Запустите сервер: `npm run serve`
|
||||
3. Откройте интерфейс: `http://localhost:4173`
|
||||
|
||||
### Переменные окружения
|
||||
- `EMAIL_GEN_ROOT` — путь к корню `email-gen`
|
||||
- `EMAIL_GEN_PROJECT` — имя проекта (по умолчанию `vipavenue`)
|
||||
- `EMAIL_GEN_HTML_PATH` — путь к `html.pug` внутри `email-gen` (опционально)
|
||||
- `EMAIL_GEN_LETTERS_DIR` — путь к папке `letters` внутри `email-gen` (опционально)
|
||||
- `EMAIL_GEN_PUBLIC_INDEX` — путь к `public/index.html` (опционально)
|
||||
- `PORT` — порт сервера (по умолчанию `4173`)
|
||||
|
||||
Кнопка `HTML` в конструкторе копирует содержимое `public/index.html` через `/api/html`.
|
||||
|
||||
### Dev-режим (опционально)
|
||||
Если запускаете `npm run dev`, укажите `VITE_API_BASE=http://localhost:4173`,
|
||||
чтобы кнопка сохранения обращалась к backend.
|
||||
|
||||
## Общие элементы
|
||||
- **Тёмная тема** — переключатель в топбаре. Сохраняется в `localStorage`.
|
||||
- **Папка изображений** — глобальный префикс для всех картинок. В блоках указывается только имя файла и расширение.
|
||||
- **Цены в товарах** — один глобальный флажок. Управляет всеми товарными блоками (включая «3 товара + картинка»).
|
||||
- **Собрать мужское / Собрать женское** — переставляет сегменты относительно разделителя (`dividerVA`), сохраняя общие блоки до и после.
|
||||
- **Сохранить как пресет** — сохраняет текущее состояние блоков и настроек.
|
||||
- **Сбросить** — очищает блоки и Pug.
|
||||
|
||||
## Работа с блоками
|
||||
- Добавляйте блок через выпадающий список «Тип блока».
|
||||
- Каждый блок имеет сегмент: **O** (общий), **Ж**, **М**. Сегментный бейдж меняется при клике.
|
||||
- Перетаскивайте блоки за «ручку» или кнопками ↑/↓.
|
||||
- Параметры блока сразу попадают в Pug (панель справа).
|
||||
- Кнопки «Скопировать код» / «Экспорт .pug» работают сверху и снизу панели кода.
|
||||
|
||||
### Сегменты и сборка версий
|
||||
- Общие блоки **до** разделителя `dividerVA` всегда остаются сверху.
|
||||
- При сборке **мужской** версии: сегмент М становится над разделителем, Ж — под ним; общие блоки вокруг разделителя остаются на месте.
|
||||
- При возвращении на **женскую** версию порядок восстанавливается.
|
||||
|
||||
## Пресеты
|
||||
- Вкладка «Пресеты»: Новинки, Акция, Новые коллекции, Мои пресеты.
|
||||
- Для Новинок/Акции/Новых коллекций введите женские и мужские ID (до 16), при необходимости цены.
|
||||
- Кнопки «Женская версия» / «Мужская версия» создают набор блоков и переходят в конструктор.
|
||||
- «Мои пресеты» — сохранённые состояния конструктора. Доступны загрузка/удаление.
|
||||
|
||||
## Поля и картинки
|
||||
- В блоках с картинками указывайте только имя файла и расширение; базовый путь — в «Папка изображений».
|
||||
- Расширение по умолчанию: `.png`.
|
||||
|
||||
## Хранение данных
|
||||
- `localStorage` ключи:
|
||||
- `vip_letter_editor_blocks_v1`
|
||||
- `vip_letter_editor_settings_v1` (включая `imageBaseUrl`, `showPrices`, тема)
|
||||
- `vip_letter_editor_theme`
|
||||
- `vip_letter_editor_custom_presets_v1`
|
||||
|
||||
## Частые действия
|
||||
- **Показать превью HTML для текстового блока** — чекбокс внутри текстового блока (если нужен рендер HTML).
|
||||
- **Убрать отступ после блока** — флажок внутри блока.
|
||||
- **Ширина/высота, отступы** — в доп. настройках (кнопка «Доп. настройки» там, где она есть).
|
||||
|
||||
Если что-то пошло не так: проверьте, что глобальный флажок цен включён/выключен как нужно, и убедитесь, что разделитель `dividerVA` стоит на месте, если используете сборку мужской/женской версии.
|
||||
265
aspekter_ref/editor-svelte/dist/assets/index-BUFzIg5U.js
vendored
Normal file
265
aspekter_ref/editor-svelte/dist/assets/index-BUFzIg5U.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
aspekter_ref/editor-svelte/dist/assets/index-CvxZHvQj.css
vendored
Normal file
1
aspekter_ref/editor-svelte/dist/assets/index-CvxZHvQj.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
aspekter_ref/editor-svelte/dist/favicon.svg
vendored
Normal file
4
aspekter_ref/editor-svelte/dist/favicon.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2.2" />
|
||||
<path d="M4 7l8 6 8-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
14
aspekter_ref/editor-svelte/dist/index.html
vendored
Normal file
14
aspekter_ref/editor-svelte/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>aspekter — конструктор писем</title>
|
||||
<script type="module" crossorigin src="/assets/index-BUFzIg5U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CvxZHvQj.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
149
aspekter_ref/editor-svelte/dist/readme.html
vendored
Normal file
149
aspekter_ref/editor-svelte/dist/readme.html
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Инструкция по конструктору писем VipAvenue</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0f1115;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #dcdcaa;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
a { color: #9abcf9; }
|
||||
code { background: #1e1e1e; padding: 2px 4px; border-radius: 4px; }
|
||||
pre { background: #1e1e1e; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
ul { margin: 0.4em 0 0.8em 1.4em; }
|
||||
.section { margin-bottom: 1.6em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Конструктор писем VipAvenue (Svelte)</h1>
|
||||
<p>Максимально подробная инструкция: запуск, глобальные настройки, блоки, сегменты, пресеты, экспорт и хранение.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Запуск и окружение</h2>
|
||||
<ol>
|
||||
<li><code>npm install</code> — установка зависимостей.</li>
|
||||
<li><code>npm run dev</code> — запуск dev-сервера.</li>
|
||||
<li>Открыть адрес из терминала (обычно <code>http://localhost:5173</code>).</li>
|
||||
</ol>
|
||||
<p>Все состояния (блоки, настройки, тема, кастомные пресеты) хранятся в <code>localStorage</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Макет интерфейса</h2>
|
||||
<ul>
|
||||
<li><strong>Топбар</strong>: переключатель темы (тёмная/светлая, сохраняется).</li>
|
||||
<li><strong>Левая колонка — Конструктор</strong>:
|
||||
<ul>
|
||||
<li>Папка изображений — глобальный префикс URL.</li>
|
||||
<li>Цены в товарах — общий флажок для всех товарных блоков.</li>
|
||||
<li>Тип блока — добавление нового блока.</li>
|
||||
<li>Собрать мужское / Собрать женское — обмен сегментов относительно разделителя.</li>
|
||||
<li>Сохранить как пресет — сохраняет текущее состояние (блоки + настройки).</li>
|
||||
<li>Сбросить — удаляет все блоки и код.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Левая колонка — Пресеты</strong>: Новинки, Акция, Новые коллекции, Мои пресеты.</li>
|
||||
<li><strong>Правая колонка</strong>: живой Pug-код, кнопки копирования/экспорта (дублируются сверху и снизу).</li>
|
||||
<li><strong>Футер</strong>: цитата и ссылка на инструкцию.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Блоки и сегменты</h2>
|
||||
<ul>
|
||||
<li>Сегменты: <strong>O</strong> — общий, <strong>Ж</strong> — женский, <strong>М</strong> — мужской. Меняются кликом по бейджу в заголовке.</li>
|
||||
<li>Перетаскивание: за «ручку» или кнопки ↑/↓.</li>
|
||||
<li>Все поля блока моментально обновляют Pug справа.</li>
|
||||
<li>Опция «Убрать отступ после блока» позволяет стыковать плотнее.</li>
|
||||
<li>Глобальный флажок «Цены в товарах» управляет всеми товарными блоками, включая «3 товара + картинка».</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Сборка женской/мужской версии</h2>
|
||||
<ul>
|
||||
<li><code>dividerVA</code> — центр обмена сегментов.</li>
|
||||
<li>Общие блоки до/после разделителя остаются на местах.</li>
|
||||
<li>При «Собрать мужское» блоки М поднимаются над разделителем, блоки Ж опускаются под него.</li>
|
||||
<li>При «Собрать женское» порядок возвращается (запоминается при переключении на мужскую).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Перечень блоков</h2>
|
||||
<ul>
|
||||
<li><strong>Заголовки/текст</strong>: АКТУАЛЬНЫЙ заголовок, Текстовый блок (поддержка <code><br></code>, <code> </code>, <code>—</code>, жирный через <code><span style="font-weight:700;"></code>). Есть HTML-превью.</li>
|
||||
<li><strong>Кнопки</strong>: одиночная, двойная, тройная. Настройки ширины/высоты/цветов/шрифта/отступов.</li>
|
||||
<li><strong>Баннеры</strong>: с ссылкой/без, 2/3 баннера (с/без текста). Имя файла + расширение, префикс берётся из «Папка изображений».</li>
|
||||
<li><strong>Товары</strong>:
|
||||
<ul>
|
||||
<li>4 в ряд, 3 в ряд.</li>
|
||||
<li>Товары + картинка слева/справа; 3 товара + картинка слева/справа.</li>
|
||||
<li>Цены регулируются глобальным флажком.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Текст + картинка</strong>: картинка слева/справа, кнопка под текстом.</li>
|
||||
<li><strong>Сервис</strong>: Отступ, Разделитель (<code>dividerVA</code>), Размерная сетка, Промокод.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Картинки</h2>
|
||||
<ul>
|
||||
<li>Заполните «Папка изображений» (например, <code>https://email-files.vipavenue.ru/newsletter_2025/2025_12_07/</code>).</li>
|
||||
<li>В блоках — только имя файла и расширение (<code>.png</code> по умолчанию). При необходимости есть поля расширения и прямой URL (fallback).</li>
|
||||
<li>URL собирается автоматически: префикс + имя файла + расширение.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Пресеты</h2>
|
||||
<ul>
|
||||
<li><strong>Новинки</strong>: 16 женских + 16 мужских ID, заголовок, подзаголовок, кнопки, ссылки размерных сеток, кнопки «Больше новинок». Цены — глобальным флажком.</li>
|
||||
<li><strong>Акция</strong>: баннер, два блока товаров (жен/муж), разделитель, кнопки «Подборка для неё/него».</li>
|
||||
<li><strong>Новые коллекции</strong>: похожий набор с отдельными текстами/ссылками/баннером.</li>
|
||||
<li><strong>Мои пресеты</strong>: сохранённые состояния конструктора (блоки + настройки). Доступны загрузка/удаление.</li>
|
||||
<li>Кнопки «Женская версия» / «Мужская версия» применяют пресет и возвращают на вкладку Конструктор.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Экспорт Pug</h2>
|
||||
<ul>
|
||||
<li>Поле справа всегда содержит актуальный Pug.</li>
|
||||
<li>«Скопировать код» — в буфер; «Экспорт .pug» — скачивает файл <code>letter.pug</code>.</li>
|
||||
<li>Кнопки копирования/экспорта продублированы сверху и снизу панели кода.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>9. Хранение данных (localStorage)</h2>
|
||||
<ul>
|
||||
<li><code>vip_letter_editor_blocks_v1</code> — блоки.</li>
|
||||
<li><code>vip_letter_editor_settings_v1</code> — настройки (включая <code>imageBaseUrl</code>, <code>showPrices</code>).</li>
|
||||
<li><code>vip_letter_editor_theme</code> — тема.</li>
|
||||
<li><code>vip_letter_editor_custom_presets_v1</code> — пользовательские пресеты.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>10. Практические советы</h2>
|
||||
<ul>
|
||||
<li>Следите, чтобы <code>dividerVA</code> стоял на месте, если используете сборку мужской/женской версии.</li>
|
||||
<li>После «Собрать мужское» вернуться на женскую можно той же кнопкой (текст меняется).</li>
|
||||
<li>Для плотного стыка блоков снимайте «Отступ после блока».</li>
|
||||
<li>В текстовых блоках включайте HTML-превью, если хотите увидеть рендер <code><br></code>/<code> </code>/<code>—</code> и жирного текста.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
aspekter_ref/editor-svelte/html.pug
Normal file
17
aspekter_ref/editor-svelte/html.pug
Normal file
@@ -0,0 +1,17 @@
|
||||
extends layout/layout.pug
|
||||
//- Для задания класса основной таблицы в которой находится весь контент письма
|
||||
//- В данном случае в комментарии установлен класс для темного письма
|
||||
|
||||
//- prepend wrapper
|
||||
//- -
|
||||
//- var wrapperClass = "blackMainBackground";
|
||||
|
||||
|
||||
block header
|
||||
include ./parts/header/header-man
|
||||
block preheader
|
||||
+preheader("Премиальные шубы и дубленки <vk-snippet-end/>")
|
||||
block content
|
||||
include ./letters/let/let
|
||||
block footer
|
||||
include ./parts/footer/footer-man
|
||||
13
aspekter_ref/editor-svelte/index.html
Normal file
13
aspekter_ref/editor-svelte/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>aspekter — конструктор писем</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1261
aspekter_ref/editor-svelte/package-lock.json
generated
Normal file
1261
aspekter_ref/editor-svelte/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
aspekter_ref/editor-svelte/package.json
Normal file
22
aspekter_ref/editor-svelte/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "editor-svelte",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"serve": "node server/index.js",
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte-dnd-action": "^0.9.25",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"svelte": "^4.2.12",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
4
aspekter_ref/editor-svelte/public/favicon.svg
Normal file
4
aspekter_ref/editor-svelte/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2.2" />
|
||||
<path d="M4 7l8 6 8-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
149
aspekter_ref/editor-svelte/public/readme.html
Normal file
149
aspekter_ref/editor-svelte/public/readme.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Инструкция по конструктору писем VipAvenue</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0f1115;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #dcdcaa;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
a { color: #9abcf9; }
|
||||
code { background: #1e1e1e; padding: 2px 4px; border-radius: 4px; }
|
||||
pre { background: #1e1e1e; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
ul { margin: 0.4em 0 0.8em 1.4em; }
|
||||
.section { margin-bottom: 1.6em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Конструктор писем VipAvenue (Svelte)</h1>
|
||||
<p>Максимально подробная инструкция: запуск, глобальные настройки, блоки, сегменты, пресеты, экспорт и хранение.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Запуск и окружение</h2>
|
||||
<ol>
|
||||
<li><code>npm install</code> — установка зависимостей.</li>
|
||||
<li><code>npm run dev</code> — запуск dev-сервера.</li>
|
||||
<li>Открыть адрес из терминала (обычно <code>http://localhost:5173</code>).</li>
|
||||
</ol>
|
||||
<p>Все состояния (блоки, настройки, тема, кастомные пресеты) хранятся в <code>localStorage</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Макет интерфейса</h2>
|
||||
<ul>
|
||||
<li><strong>Топбар</strong>: переключатель темы (тёмная/светлая, сохраняется).</li>
|
||||
<li><strong>Левая колонка — Конструктор</strong>:
|
||||
<ul>
|
||||
<li>Папка изображений — глобальный префикс URL.</li>
|
||||
<li>Цены в товарах — общий флажок для всех товарных блоков.</li>
|
||||
<li>Тип блока — добавление нового блока.</li>
|
||||
<li>Собрать мужское / Собрать женское — обмен сегментов относительно разделителя.</li>
|
||||
<li>Сохранить как пресет — сохраняет текущее состояние (блоки + настройки).</li>
|
||||
<li>Сбросить — удаляет все блоки и код.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Левая колонка — Пресеты</strong>: Новинки, Акция, Новые коллекции, Мои пресеты.</li>
|
||||
<li><strong>Правая колонка</strong>: живой Pug-код, кнопки копирования/экспорта (дублируются сверху и снизу).</li>
|
||||
<li><strong>Футер</strong>: цитата и ссылка на инструкцию.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Блоки и сегменты</h2>
|
||||
<ul>
|
||||
<li>Сегменты: <strong>O</strong> — общий, <strong>Ж</strong> — женский, <strong>М</strong> — мужской. Меняются кликом по бейджу в заголовке.</li>
|
||||
<li>Перетаскивание: за «ручку» или кнопки ↑/↓.</li>
|
||||
<li>Все поля блока моментально обновляют Pug справа.</li>
|
||||
<li>Опция «Убрать отступ после блока» позволяет стыковать плотнее.</li>
|
||||
<li>Глобальный флажок «Цены в товарах» управляет всеми товарными блоками, включая «3 товара + картинка».</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Сборка женской/мужской версии</h2>
|
||||
<ul>
|
||||
<li><code>dividerVA</code> — центр обмена сегментов.</li>
|
||||
<li>Общие блоки до/после разделителя остаются на местах.</li>
|
||||
<li>При «Собрать мужское» блоки М поднимаются над разделителем, блоки Ж опускаются под него.</li>
|
||||
<li>При «Собрать женское» порядок возвращается (запоминается при переключении на мужскую).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Перечень блоков</h2>
|
||||
<ul>
|
||||
<li><strong>Заголовки/текст</strong>: АКТУАЛЬНЫЙ заголовок, Текстовый блок (поддержка <code><br></code>, <code> </code>, <code>—</code>, жирный через <code><span style="font-weight:700;"></code>). Есть HTML-превью.</li>
|
||||
<li><strong>Кнопки</strong>: одиночная, двойная, тройная. Настройки ширины/высоты/цветов/шрифта/отступов.</li>
|
||||
<li><strong>Баннеры</strong>: с ссылкой/без, 2/3 баннера (с/без текста). Имя файла + расширение, префикс берётся из «Папка изображений».</li>
|
||||
<li><strong>Товары</strong>:
|
||||
<ul>
|
||||
<li>4 в ряд, 3 в ряд.</li>
|
||||
<li>Товары + картинка слева/справа; 3 товара + картинка слева/справа.</li>
|
||||
<li>Цены регулируются глобальным флажком.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Текст + картинка</strong>: картинка слева/справа, кнопка под текстом.</li>
|
||||
<li><strong>Сервис</strong>: Отступ, Разделитель (<code>dividerVA</code>), Размерная сетка, Промокод.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Картинки</h2>
|
||||
<ul>
|
||||
<li>Заполните «Папка изображений» (например, <code>https://email-files.vipavenue.ru/newsletter_2025/2025_12_07/</code>).</li>
|
||||
<li>В блоках — только имя файла и расширение (<code>.png</code> по умолчанию). При необходимости есть поля расширения и прямой URL (fallback).</li>
|
||||
<li>URL собирается автоматически: префикс + имя файла + расширение.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Пресеты</h2>
|
||||
<ul>
|
||||
<li><strong>Новинки</strong>: 16 женских + 16 мужских ID, заголовок, подзаголовок, кнопки, ссылки размерных сеток, кнопки «Больше новинок». Цены — глобальным флажком.</li>
|
||||
<li><strong>Акция</strong>: баннер, два блока товаров (жен/муж), разделитель, кнопки «Подборка для неё/него».</li>
|
||||
<li><strong>Новые коллекции</strong>: похожий набор с отдельными текстами/ссылками/баннером.</li>
|
||||
<li><strong>Мои пресеты</strong>: сохранённые состояния конструктора (блоки + настройки). Доступны загрузка/удаление.</li>
|
||||
<li>Кнопки «Женская версия» / «Мужская версия» применяют пресет и возвращают на вкладку Конструктор.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Экспорт Pug</h2>
|
||||
<ul>
|
||||
<li>Поле справа всегда содержит актуальный Pug.</li>
|
||||
<li>«Скопировать код» — в буфер; «Экспорт .pug» — скачивает файл <code>letter.pug</code>.</li>
|
||||
<li>Кнопки копирования/экспорта продублированы сверху и снизу панели кода.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>9. Хранение данных (localStorage)</h2>
|
||||
<ul>
|
||||
<li><code>vip_letter_editor_blocks_v1</code> — блоки.</li>
|
||||
<li><code>vip_letter_editor_settings_v1</code> — настройки (включая <code>imageBaseUrl</code>, <code>showPrices</code>).</li>
|
||||
<li><code>vip_letter_editor_theme</code> — тема.</li>
|
||||
<li><code>vip_letter_editor_custom_presets_v1</code> — пользовательские пресеты.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>10. Практические советы</h2>
|
||||
<ul>
|
||||
<li>Следите, чтобы <code>dividerVA</code> стоял на месте, если используете сборку мужской/женской версии.</li>
|
||||
<li>После «Собрать мужское» вернуться на женскую можно той же кнопкой (текст меняется).</li>
|
||||
<li>Для плотного стыка блоков снимайте «Отступ после блока».</li>
|
||||
<li>В текстовых блоках включайте HTML-превью, если хотите увидеть рендер <code><br></code>/<code> </code>/<code>—</code> и жирного текста.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
279
aspekter_ref/editor-svelte/server/index.js
Normal file
279
aspekter_ref/editor-svelte/server/index.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import http from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
const distDir = path.resolve(__dirname, "..", "dist");
|
||||
|
||||
const emailGenRoot = path.resolve(process.env.EMAIL_GEN_ROOT || path.resolve(__dirname, "..", "..", "email-gen"));
|
||||
const emailGenProject = process.env.EMAIL_GEN_PROJECT || "vipavenue";
|
||||
const htmlRelPath = process.env.EMAIL_GEN_HTML_PATH || `emails/${emailGenProject}/html.pug`;
|
||||
const lettersRelDir = process.env.EMAIL_GEN_LETTERS_DIR || `emails/${emailGenProject}/letters`;
|
||||
const publicIndexRel = process.env.EMAIL_GEN_PUBLIC_INDEX || "public/index.html";
|
||||
const allowOrigin = process.env.API_ALLOW_ORIGIN || "*";
|
||||
|
||||
const contentTypes = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".ico": "image/x-icon",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8"
|
||||
};
|
||||
|
||||
const json = (res, status, payload) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const text = (res, status, payload) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(payload);
|
||||
};
|
||||
|
||||
const safeResolve = (root, rel) => {
|
||||
const target = path.resolve(root, rel);
|
||||
const safeRoot = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
if (!target.startsWith(safeRoot)) {
|
||||
throw new Error("Path escapes root");
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const normalizeContentPath = (value = "") =>
|
||||
value
|
||||
.replace(/^(\.\/)?letters\//i, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\.pug$/i, "")
|
||||
.trim();
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 5 * 1024 * 1024) {
|
||||
reject(new Error("Payload too large"));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => resolve(data));
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (pathname === "/api/save") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const raw = await readBody(req);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
json(res, 400, { ok: false, error: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
const { pugCode, htmlPug, contentPath } = payload || {};
|
||||
if (typeof pugCode !== "string" || !pugCode.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing pugCode" });
|
||||
return;
|
||||
}
|
||||
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing htmlPug" });
|
||||
return;
|
||||
}
|
||||
if (typeof contentPath !== "string" || !contentPath.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing contentPath" });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeContentPath(contentPath);
|
||||
if (!normalizedPath || normalizedPath.includes("..")) {
|
||||
json(res, 400, { ok: false, error: "Invalid contentPath" });
|
||||
return;
|
||||
}
|
||||
|
||||
const letterRelPath = path.posix.join(lettersRelDir, `${normalizedPath}.pug`);
|
||||
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
|
||||
const letterTarget = safeResolve(emailGenRoot, letterRelPath);
|
||||
|
||||
await fs.mkdir(path.dirname(letterTarget), { recursive: true });
|
||||
await fs.writeFile(letterTarget, pugCode, "utf8");
|
||||
await fs.writeFile(htmlTarget, htmlPug, "utf8");
|
||||
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
saved: {
|
||||
letter: letterRelPath,
|
||||
html: htmlRelPath
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/save-html") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const raw = await readBody(req);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
json(res, 400, { ok: false, error: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
const { htmlPug } = payload || {};
|
||||
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing htmlPug" });
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
|
||||
await fs.writeFile(htmlTarget, htmlPug, "utf8");
|
||||
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
saved: {
|
||||
html: htmlRelPath
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/html") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
|
||||
try {
|
||||
const data = await fs.readFile(previewPath);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
text(res, 404, "Preview not found. Run the email generator.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/preview") {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
|
||||
try {
|
||||
const data = await fs.readFile(previewPath);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
text(res, 404, "Preview not found. Run the email generator.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
text(res, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const safePath = path.resolve(distDir, "." + pathname);
|
||||
const safeRoot = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
|
||||
if (!safePath.startsWith(safeRoot)) {
|
||||
text(res, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = safePath;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Not a file");
|
||||
}
|
||||
} catch (e) {
|
||||
filePath = path.join(distDir, "index.html");
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = contentTypes[ext] || "application/octet-stream";
|
||||
let data;
|
||||
try {
|
||||
data = await fs.readFile(filePath);
|
||||
} catch (e) {
|
||||
text(res, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
text(res, 500, "Internal Server Error");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`editor-svelte server running on http://localhost:${PORT}`);
|
||||
console.log(`email-gen root: ${emailGenRoot}`);
|
||||
});
|
||||
2398
aspekter_ref/editor-svelte/src/App.svelte
Normal file
2398
aspekter_ref/editor-svelte/src/App.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1443
aspekter_ref/editor-svelte/src/app.css
Normal file
1443
aspekter_ref/editor-svelte/src/app.css
Normal file
File diff suppressed because it is too large
Load Diff
479
aspekter_ref/editor-svelte/src/components/BlockCard.svelte
Normal file
479
aspekter_ref/editor-svelte/src/components/BlockCard.svelte
Normal file
@@ -0,0 +1,479 @@
|
||||
<script>
|
||||
import SpacerBlock from "./blocks/SpacerBlock.svelte";
|
||||
import TitleActualBlock from "./blocks/TitleActualBlock.svelte";
|
||||
import ParagraphBlock from "./blocks/ParagraphBlock.svelte";
|
||||
import ButtonSingleBlock from "./blocks/ButtonSingleBlock.svelte";
|
||||
import ButtonDoubleBlock from "./blocks/ButtonDoubleBlock.svelte";
|
||||
import ButtonTripleBlock from "./blocks/ButtonTripleBlock.svelte";
|
||||
import BannerBlock from "./blocks/BannerBlock.svelte";
|
||||
import BannerNoLinkBlock from "./blocks/BannerNoLinkBlock.svelte";
|
||||
import TwoBannersWithTextBlock from "./blocks/TwoBannersWithTextBlock.svelte";
|
||||
import TwoBannersNoTextBlock from "./blocks/TwoBannersNoTextBlock.svelte";
|
||||
import ThreeBannersNoTextBlock from "./blocks/ThreeBannersNoTextBlock.svelte";
|
||||
import ProductsRowBlock from "./blocks/ProductsRowBlock.svelte";
|
||||
import ProductsImageBlock from "./blocks/ProductsImageBlock.svelte";
|
||||
import TextImageBlock from "./blocks/TextImageBlock.svelte";
|
||||
import SizeGridBlock from "./blocks/SizeGridBlock.svelte";
|
||||
import PromocodeBlock from "./blocks/PromocodeBlock.svelte";
|
||||
import DividerBlock from "./blocks/DividerBlock.svelte";
|
||||
|
||||
export let block;
|
||||
export let index;
|
||||
export let femaleIndex = 0;
|
||||
export let maleIndex = 0;
|
||||
export let onChange;
|
||||
export let onRemove;
|
||||
export let forceCollapse = null;
|
||||
export let colorizeTitles = false;
|
||||
export let onHandlePointerDown = () => {};
|
||||
export let isDragging = false;
|
||||
|
||||
const handleChange = (data) => onChange(block.id, data);
|
||||
|
||||
const segmentLabelFor = (seg, fIdx, mIdx) => {
|
||||
if (seg === "female") return `Ж${fIdx || ""}`;
|
||||
if (seg === "male") return `М${mIdx || ""}`;
|
||||
return "О";
|
||||
};
|
||||
|
||||
let segLocal = block.segment || "common";
|
||||
let segBadge = segmentLabelFor(segLocal, femaleIndex, maleIndex);
|
||||
$: segLocal = block.segment || "common";
|
||||
$: segBadge = segmentLabelFor(segLocal, femaleIndex, maleIndex);
|
||||
|
||||
let collapsed = false;
|
||||
$: if (forceCollapse !== null) collapsed = forceCollapse;
|
||||
|
||||
const setSegment = (value) => {
|
||||
segLocal = value;
|
||||
segBadge = segmentLabelFor(value);
|
||||
onChange(block.id, { segment: value });
|
||||
};
|
||||
|
||||
const getBlockLabel = (type) => {
|
||||
switch (type) {
|
||||
case "titleActual":
|
||||
return "АКТУАЛЬНЫЙ заголовок";
|
||||
case "paragraph":
|
||||
return "Текстовый блок";
|
||||
case "buttonSingle":
|
||||
return "Кнопка по центру";
|
||||
case "buttonDouble":
|
||||
return "Две кнопки";
|
||||
case "buttonTriple":
|
||||
return "Три кнопки";
|
||||
case "banner":
|
||||
return "Баннер с ссылкой";
|
||||
case "bannerNoLink":
|
||||
return "Баннер без ссылки";
|
||||
case "twoBannersWithText":
|
||||
return "Два баннера с текстом";
|
||||
case "twoBannersNoText":
|
||||
return "Два баннера без текста";
|
||||
case "threeBannersNoText":
|
||||
return "Три баннера без текста";
|
||||
case "products4Row":
|
||||
return "4 товара в ряд";
|
||||
case "products3Row":
|
||||
return "3 товара в ряд";
|
||||
case "productsImageLeft":
|
||||
return "Товары + картинка слева";
|
||||
case "productsImageRight":
|
||||
return "Товары + картинка справа";
|
||||
case "productsImageLeft3":
|
||||
return "3 товара + картинка слева";
|
||||
case "productsImageRight3":
|
||||
return "3 товара + картинка справа";
|
||||
case "textImageLeft":
|
||||
return "Текст справа, картинка слева";
|
||||
case "textImageRight":
|
||||
return "Текст слева, картинка справа";
|
||||
case "sizeGrid":
|
||||
return "Размерная сетка";
|
||||
case "promocode":
|
||||
return "Промокод";
|
||||
case "spacer":
|
||||
return "Отступ";
|
||||
case "dividerVA":
|
||||
return "Разделитель";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollapse = () => (collapsed = !collapsed);
|
||||
|
||||
const handleKeyToggle = (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleCollapse();
|
||||
}
|
||||
};
|
||||
|
||||
const typeColors = {
|
||||
titleActual: "#e85a5a",
|
||||
paragraph: "#b388ff",
|
||||
buttonSingle: "#2ab27b",
|
||||
buttonDouble: "#26a69a",
|
||||
buttonTriple: "#0097a7",
|
||||
banner: "#ffb74d",
|
||||
bannerNoLink: "#ff9800",
|
||||
twoBannersWithText: "#c27447",
|
||||
twoBannersNoText: "#bf6d3f",
|
||||
threeBannersNoText: "#b0552c",
|
||||
products4Row: "#4dabf5",
|
||||
products3Row: "#42a5f5",
|
||||
productsImageLeft: "#ec6a7d",
|
||||
productsImageRight: "#ec6a7d",
|
||||
productsImageLeft3: "#ec6a7d",
|
||||
productsImageRight3: "#ec6a7d",
|
||||
textImageLeft: "#7fd1b9",
|
||||
textImageRight: "#7fd1b9",
|
||||
sizeGrid: "#90a4ae",
|
||||
promocode: "#f06292",
|
||||
spacer: "#cfd8dc",
|
||||
dividerVA: "#8bc34a"
|
||||
};
|
||||
|
||||
const computeTitleColor = (blk, enabled) => {
|
||||
if (!enabled) return null;
|
||||
return typeColors[blk?.type] || "#9aa0a6";
|
||||
};
|
||||
|
||||
// пересчитываем при смене палитры или изменении блока
|
||||
$: titleColor = computeTitleColor(block, colorizeTitles);
|
||||
|
||||
const PRODUCT_REQUIREMENTS = {
|
||||
products4Row: { field: "productIds", count: 4 },
|
||||
products3Row: { field: "productIds", count: 3 },
|
||||
productsImageLeft: { field: "productIds", count: 4 },
|
||||
productsImageRight: { field: "productIds", count: 4 },
|
||||
productsImageLeft3: { field: "productIds", count: 3 },
|
||||
productsImageRight3: { field: "productIds", count: 3 }
|
||||
};
|
||||
|
||||
const cleanText = (value = "") => {
|
||||
const stripped = value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
return stripped;
|
||||
};
|
||||
|
||||
const textPreview = (value = "", limit = 80) => {
|
||||
const text = cleanText(value);
|
||||
if (!text) return "";
|
||||
return text.length > limit ? `${text.slice(0, limit - 1).trim()}…` : text;
|
||||
};
|
||||
|
||||
const shortLink = (value = "", limit = 32) => {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim().replace(/^https?:\/\//i, "");
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}…` : trimmed;
|
||||
};
|
||||
|
||||
const splitList = (value = "") =>
|
||||
value
|
||||
.split(/[\n,]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const formatButtons = (buttons) =>
|
||||
buttons
|
||||
.map((btn) => `${btn.label}: ${textPreview(btn.text || btn.image || "—", 18) || "—"} → ${shortLink(btn.href) || "нет"}`)
|
||||
.join(" • ");
|
||||
|
||||
const getProductInfo = (blk) => {
|
||||
const requirement = PRODUCT_REQUIREMENTS[blk?.type];
|
||||
if (!requirement) return null;
|
||||
const raw = blk?.data?.[requirement.field] || "";
|
||||
const ids = splitList(raw);
|
||||
return {
|
||||
summary: `ID ${ids.length}/${requirement.count}`,
|
||||
count: ids.length,
|
||||
expected: requirement.count
|
||||
};
|
||||
};
|
||||
|
||||
const computeSummary = (blk) => {
|
||||
if (!blk) return "";
|
||||
const data = blk.data || {};
|
||||
switch (blk.type) {
|
||||
case "titleActual":
|
||||
case "paragraph":
|
||||
return textPreview(data.text);
|
||||
case "buttonSingle":
|
||||
return `${textPreview(data.text, 26) || "Кнопка"} → ${shortLink(data.href) || "нет ссылки"}`;
|
||||
case "buttonDouble":
|
||||
return formatButtons([
|
||||
{ label: "Л", text: data.leftText, href: data.leftHref },
|
||||
{ label: "П", text: data.rightText, href: data.rightHref }
|
||||
]);
|
||||
case "buttonTriple":
|
||||
return formatButtons([
|
||||
{ label: "Л", text: data.leftText, href: data.leftHref },
|
||||
{ label: "С", text: data.centerText, href: data.centerHref },
|
||||
{ label: "П", text: data.rightText, href: data.rightHref }
|
||||
]);
|
||||
case "banner":
|
||||
return `${data.imageBaseName ? `${data.imageBaseName}${data.imageExtension || ".png"}` : "Файл не задан"} → ${
|
||||
shortLink(data.href) || "нет ссылки"
|
||||
}`;
|
||||
case "bannerNoLink":
|
||||
return data.imageBaseName ? `${data.imageBaseName}${data.imageExtension || ".png"}` : "";
|
||||
case "twoBannersWithText":
|
||||
return formatButtons([
|
||||
{ label: "Л", text: data.leftText || data.leftImageBaseName, href: data.leftHref },
|
||||
{ label: "П", text: data.rightText || data.rightImageBaseName, href: data.rightHref }
|
||||
]);
|
||||
case "twoBannersNoText":
|
||||
return formatButtons([
|
||||
{ label: "Л", text: data.leftImageBaseName, href: data.leftHref },
|
||||
{ label: "П", text: data.rightImageBaseName, href: data.rightHref }
|
||||
]);
|
||||
case "threeBannersNoText":
|
||||
return [1, 2, 3]
|
||||
.map((num, idx) => {
|
||||
const image = data[`imgBaseName${num}`];
|
||||
const href = data[`href${num}`];
|
||||
const label = ["Л", "Ц", "П"][idx];
|
||||
return `${label}: ${image || "—"}${href ? ` → ${shortLink(href)}` : ""}`;
|
||||
})
|
||||
.join(" • ");
|
||||
case "products4Row":
|
||||
case "products3Row":
|
||||
case "productsImageLeft":
|
||||
case "productsImageRight":
|
||||
case "productsImageLeft3":
|
||||
case "productsImageRight3":
|
||||
return getProductInfo(blk)?.summary || "";
|
||||
case "textImageLeft":
|
||||
case "textImageRight":
|
||||
return `${textPreview(data.header, 32) || "Без заголовка"} • ${data.buttonText || "кнопка"}`;
|
||||
case "sizeGrid":
|
||||
return `Размеры ${splitList(data.sizes).length}${data.links ? ` • Ссылки ${splitList(data.links).length}` : ""}`;
|
||||
case "promocode":
|
||||
return data.code ? `Код: ${data.code}` : "";
|
||||
case "spacer":
|
||||
return `${data.height ?? 40}px`;
|
||||
case "dividerVA":
|
||||
return `${data.width ?? 300}×${data.height ?? 1}px`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const pushIssue = (arr, issue) => {
|
||||
if (issue && !arr.includes(issue)) arr.push(issue);
|
||||
};
|
||||
|
||||
const computeIssues = (blk) => {
|
||||
const issues = [];
|
||||
if (!blk) return issues;
|
||||
const data = blk.data || {};
|
||||
const summaryInfo = getProductInfo(blk);
|
||||
if (summaryInfo && summaryInfo.count !== summaryInfo.expected) {
|
||||
issues.push(`ID ${summaryInfo.count}/${summaryInfo.expected}`);
|
||||
}
|
||||
|
||||
const ensureText = (value, label) => {
|
||||
if (!value || !value.trim()) pushIssue(issues, label ? `Нет текста (${label})` : "Нет текста");
|
||||
};
|
||||
const ensureHref = (value, label) => {
|
||||
if (!value || !value.trim()) pushIssue(issues, label ? `Нет ссылки (${label})` : "Нет ссылки");
|
||||
};
|
||||
const ensureImage = (baseName, directUrl, label) => {
|
||||
const hasBase = baseName && baseName.trim();
|
||||
const hasDirect = directUrl && directUrl.trim();
|
||||
if (!hasBase && !hasDirect) {
|
||||
pushIssue(issues, label ? `Нет изображения (${label})` : "Нет изображения");
|
||||
}
|
||||
};
|
||||
|
||||
switch (blk.type) {
|
||||
case "titleActual":
|
||||
case "paragraph":
|
||||
ensureText(data.text);
|
||||
break;
|
||||
case "buttonSingle":
|
||||
ensureText(data.text);
|
||||
ensureHref(data.href);
|
||||
break;
|
||||
case "buttonDouble":
|
||||
ensureText(data.leftText, "Л");
|
||||
ensureHref(data.leftHref, "Л");
|
||||
ensureText(data.rightText, "П");
|
||||
ensureHref(data.rightHref, "П");
|
||||
break;
|
||||
case "buttonTriple":
|
||||
ensureText(data.leftText, "Л");
|
||||
ensureHref(data.leftHref, "Л");
|
||||
ensureText(data.centerText, "С");
|
||||
ensureHref(data.centerHref, "С");
|
||||
ensureText(data.rightText, "П");
|
||||
ensureHref(data.rightHref, "П");
|
||||
break;
|
||||
case "banner":
|
||||
ensureImage(data.imageBaseName, data.imageUrl);
|
||||
ensureHref(data.href);
|
||||
break;
|
||||
case "bannerNoLink":
|
||||
ensureImage(data.imageBaseName, data.imageUrl);
|
||||
break;
|
||||
case "twoBannersWithText":
|
||||
ensureImage(data.leftImageBaseName, data.leftImage, "Л");
|
||||
ensureHref(data.leftHref, "Л");
|
||||
ensureImage(data.rightImageBaseName, data.rightImage, "П");
|
||||
ensureHref(data.rightHref, "П");
|
||||
break;
|
||||
case "twoBannersNoText":
|
||||
ensureImage(data.leftImageBaseName, data.leftImage, "Л");
|
||||
ensureHref(data.leftHref, "Л");
|
||||
ensureImage(data.rightImageBaseName, data.rightImage, "П");
|
||||
ensureHref(data.rightHref, "П");
|
||||
break;
|
||||
case "threeBannersNoText":
|
||||
[1, 2, 3].forEach((num, idx) => {
|
||||
const label = ["Л", "Ц", "П"][idx];
|
||||
ensureImage(data[`imgBaseName${num}`], data[`img${num}`], label);
|
||||
ensureHref(data[`href${num}`], label);
|
||||
});
|
||||
break;
|
||||
case "textImageLeft":
|
||||
case "textImageRight":
|
||||
ensureText(data.header);
|
||||
ensureText(data.buttonText, "Кнопка");
|
||||
ensureHref(data.buttonHref, "Кнопка");
|
||||
ensureHref(data.link, "Картинка");
|
||||
ensureImage(data.imageBaseName, data.imageUrl, "Картинка");
|
||||
break;
|
||||
case "sizeGrid":
|
||||
if (!splitList(data.sizes).length) pushIssue(issues, "Нет размеров");
|
||||
if (data.links && splitList(data.sizes).length !== splitList(data.links).length) {
|
||||
pushIssue(issues, "Размеры ≠ ссылки");
|
||||
}
|
||||
break;
|
||||
case "promocode":
|
||||
ensureText(data.code, "Промокод");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
$: blockSummary = computeSummary(block);
|
||||
$: blockIssues = computeIssues(block);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="block-card"
|
||||
class:collapsed={collapsed}
|
||||
class:marker-female={segLocal === "female"}
|
||||
class:marker-male={segLocal === "male"}
|
||||
class:marker-common={segLocal === "common"}
|
||||
class:marker-center={block.type === "dividerVA" && block.data?.swapCenter}
|
||||
class:dragging={isDragging}
|
||||
>
|
||||
<div class="block-header">
|
||||
<div
|
||||
class="block-title clickable"
|
||||
role="button"
|
||||
aria-expanded={!collapsed}
|
||||
tabindex="0"
|
||||
on:click={toggleCollapse}
|
||||
on:keydown={handleKeyToggle}
|
||||
>
|
||||
<span class="segment-badge" title="Назначение блока">{segBadge}</span>
|
||||
<span class="block-index">{index + 1}</span>
|
||||
<span class="block-name" style:color={titleColor}>{getBlockLabel(block.type)}</span>
|
||||
</div>
|
||||
<div class="block-actions">
|
||||
<div class="segment-toggle compact">
|
||||
<button type="button" class:active={segLocal === "common"} on:click={() => setSegment("common")}>О</button>
|
||||
<button type="button" class:active={segLocal === "female"} on:click={() => setSegment("female")}>Ж</button>
|
||||
<button type="button" class:active={segLocal === "male"} on:click={() => setSegment("male")}>М</button>
|
||||
</div>
|
||||
<span
|
||||
class="block-drag-handle"
|
||||
title="Перетащить"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:pointerdown|stopPropagation={onHandlePointerDown}
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="6" cy="4.5" r="0.85" />
|
||||
<circle cx="10" cy="4.5" r="0.85" />
|
||||
<circle cx="6" cy="8" r="0.85" />
|
||||
<circle cx="10" cy="8" r="0.85" />
|
||||
<circle cx="6" cy="11.5" r="0.85" />
|
||||
<circle cx="10" cy="11.5" r="0.85" />
|
||||
</svg>
|
||||
</span>
|
||||
<button class="icon-btn danger" aria-label="Удалить" title="Удалить" on:click={() => onRemove(block.id)}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M5 5 11 11M11 5 5 11" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if (collapsed && blockSummary) || blockIssues.length}
|
||||
<div class="block-meta">
|
||||
{#if collapsed && blockSummary}
|
||||
<div class="block-summary">{blockSummary}</div>
|
||||
{/if}
|
||||
{#if blockIssues.length}
|
||||
<div class="block-issues">
|
||||
{#each blockIssues as issue}
|
||||
<span class="issue-badge">{issue}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !collapsed}
|
||||
<div class="block-body">
|
||||
{#if block.type === "spacer"}
|
||||
<SpacerBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "titleActual"}
|
||||
<TitleActualBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "paragraph"}
|
||||
<ParagraphBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "buttonSingle"}
|
||||
<ButtonSingleBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "buttonDouble"}
|
||||
<ButtonDoubleBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "buttonTriple"}
|
||||
<ButtonTripleBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "banner"}
|
||||
<BannerBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "bannerNoLink"}
|
||||
<BannerNoLinkBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "twoBannersWithText"}
|
||||
<TwoBannersWithTextBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "twoBannersNoText"}
|
||||
<TwoBannersNoTextBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "threeBannersNoText"}
|
||||
<ThreeBannersNoTextBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "products4Row" || block.type === "products3Row"}
|
||||
<ProductsRowBlock {block} onChange={handleChange} />
|
||||
{:else if block.type.startsWith("productsImageLeft") || block.type.startsWith("productsImageRight")}
|
||||
<ProductsImageBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "textImageLeft" || block.type === "textImageRight"}
|
||||
<TextImageBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "sizeGrid"}
|
||||
<SizeGridBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "promocode"}
|
||||
<PromocodeBlock {block} onChange={handleChange} />
|
||||
{:else if block.type === "dividerVA"}
|
||||
<DividerBlock {block} onChange={handleChange} />
|
||||
{:else}
|
||||
<div>Редактор для "{block.type}" ещё не реализован</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-index { font-weight: 700; color: var(--muted, var(--text)); }
|
||||
.block-name { font-weight: 700; }
|
||||
</style>
|
||||
364
aspekter_ref/editor-svelte/src/components/BlockList.svelte
Normal file
364
aspekter_ref/editor-svelte/src/components/BlockList.svelte
Normal file
@@ -0,0 +1,364 @@
|
||||
<script>
|
||||
import { onDestroy } from "svelte";
|
||||
import BlockCard from "./BlockCard.svelte";
|
||||
|
||||
export let blocks = [];
|
||||
export let onChange;
|
||||
export let onRemove;
|
||||
export let onReorder;
|
||||
export let onAdd = null;
|
||||
export let blockGroups = [];
|
||||
export let colorizeTitles = false;
|
||||
export let collapseAll = null;
|
||||
|
||||
let draggingId = null;
|
||||
let placeholderIndex = -1;
|
||||
let addValue = "";
|
||||
const rowRefs = new Map();
|
||||
let pointerListenersAttached = false;
|
||||
const SEGMENT_LABELS = {
|
||||
common: "Общие блоки",
|
||||
female: "Женский сегмент",
|
||||
male: "Мужской сегмент"
|
||||
};
|
||||
|
||||
const findIndexById = (id) => blocks.findIndex((b) => b.id === id);
|
||||
$: draggingItem = draggingId ? decoratedBlocks.find((item) => item.block.id === draggingId) : null;
|
||||
|
||||
// Подсчитываем порядковые номера женских/мужских блоков
|
||||
let blocksWithSegments = [];
|
||||
$: {
|
||||
let female = 0;
|
||||
let male = 0;
|
||||
blocksWithSegments = blocks.map((b) => {
|
||||
const seg = b.segment || "common";
|
||||
const info = { block: b, femaleIndex: 0, maleIndex: 0 };
|
||||
if (seg === "female") info.femaleIndex = ++female;
|
||||
if (seg === "male") info.maleIndex = ++male;
|
||||
return info;
|
||||
});
|
||||
}
|
||||
|
||||
let decoratedBlocks = [];
|
||||
$: {
|
||||
decoratedBlocks = blocksWithSegments.map((item, idx) => {
|
||||
const prevSegment = idx > 0 ? (blocks[idx - 1]?.segment || "common") : null;
|
||||
const currentSegment = item.block.segment || "common";
|
||||
const dividerAllowed = currentSegment === "common" ? idx === 0 : true;
|
||||
return {
|
||||
...item,
|
||||
segment: currentSegment,
|
||||
showDivider: dividerAllowed && prevSegment !== currentSegment,
|
||||
dividerLabel: SEGMENT_LABELS[currentSegment] || "Блоки"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function trackRow(node, id) {
|
||||
if (!id) return;
|
||||
rowRefs.set(id, node);
|
||||
return {
|
||||
update(newId) {
|
||||
if (newId === id) return;
|
||||
rowRefs.delete(id);
|
||||
id = newId;
|
||||
if (newId) {
|
||||
rowRefs.set(newId, node);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
rowRefs.delete(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const attachPointerListeners = () => {
|
||||
if (pointerListenersAttached) return;
|
||||
pointerListenersAttached = true;
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", handlePointerRelease, { passive: false });
|
||||
window.addEventListener("pointercancel", handlePointerRelease, { passive: false });
|
||||
};
|
||||
|
||||
const detachPointerListeners = () => {
|
||||
if (!pointerListenersAttached) return;
|
||||
pointerListenersAttached = false;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerRelease);
|
||||
window.removeEventListener("pointercancel", handlePointerRelease);
|
||||
};
|
||||
|
||||
onDestroy(detachPointerListeners);
|
||||
|
||||
const getPointerY = (event) => {
|
||||
if (typeof event.clientY === "number") return event.clientY;
|
||||
if (event.touches?.length) return event.touches[0].clientY;
|
||||
if (event.changedTouches?.length) return event.changedTouches[0].clientY;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const beginPointerDrag = (id) => (event) => {
|
||||
if (event.button !== undefined && event.button !== 0) return;
|
||||
draggingId = id;
|
||||
const currentIndex = findIndexById(id);
|
||||
placeholderIndex = Math.min(blocks.length, currentIndex + 1);
|
||||
updatePlaceholderPosition(getPointerY(event));
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
attachPointerListeners();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!draggingId) return;
|
||||
event.preventDefault();
|
||||
updatePlaceholderPosition(getPointerY(event));
|
||||
};
|
||||
|
||||
const handlePointerRelease = (event) => {
|
||||
if (!draggingId) return;
|
||||
event.preventDefault();
|
||||
detachPointerListeners();
|
||||
finalizePointerDrag();
|
||||
};
|
||||
|
||||
function updatePlaceholderPosition(y) {
|
||||
const entries = [];
|
||||
blocks.forEach((block, idx) => {
|
||||
if (draggingId && block.id === draggingId) return;
|
||||
const node = rowRefs.get(block.id);
|
||||
if (!node) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
entries.push({ idx, mid: rect.top + rect.height / 2 });
|
||||
});
|
||||
|
||||
if (!entries.length) {
|
||||
placeholderIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let target = entries[entries.length - 1].idx + 1;
|
||||
for (const entry of entries) {
|
||||
if (y < entry.mid) {
|
||||
target = entry.idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
placeholderIndex = target;
|
||||
}
|
||||
|
||||
function finalizePointerDrag() {
|
||||
if (!draggingId || placeholderIndex < 0) {
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
const from = findIndexById(draggingId);
|
||||
const to = placeholderIndex;
|
||||
if (from === -1 || to === -1 || from === to) {
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
const copy = [...blocks];
|
||||
const [item] = copy.splice(from, 1);
|
||||
copy.splice(to > from ? to - 1 : to, 0, item);
|
||||
onReorder(copy);
|
||||
resetDragState();
|
||||
}
|
||||
|
||||
const resetDragState = () => {
|
||||
draggingId = null;
|
||||
placeholderIndex = -1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="blocks-container" role="list">
|
||||
{#if blocks.length === 0}
|
||||
<div class="hint">Добавляй блоки в нужном порядке.</div>
|
||||
{/if}
|
||||
|
||||
{#each decoratedBlocks as item, index (item.block.id)}
|
||||
{#if item.showDivider}
|
||||
<div class="segment-divider" data-segment={item.segment}>
|
||||
<span>{item.dividerLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if draggingId && placeholderIndex === index && draggingItem}
|
||||
<div role="listitem" use:trackRow={draggingItem.block.id}>
|
||||
<BlockCard
|
||||
block={draggingItem.block}
|
||||
index={index}
|
||||
femaleIndex={draggingItem.femaleIndex}
|
||||
maleIndex={draggingItem.maleIndex}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
onHandlePointerDown={beginPointerDrag(draggingItem.block.id)}
|
||||
forceCollapse={collapseAll}
|
||||
{colorizeTitles}
|
||||
isDragging={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.block.id !== draggingId}
|
||||
<div
|
||||
role="listitem"
|
||||
use:trackRow={item.block.id}
|
||||
>
|
||||
<BlockCard
|
||||
block={item.block}
|
||||
{index}
|
||||
femaleIndex={item.femaleIndex}
|
||||
maleIndex={item.maleIndex}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
onHandlePointerDown={beginPointerDrag(item.block.id)}
|
||||
forceCollapse={collapseAll}
|
||||
{colorizeTitles}
|
||||
isDragging={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if draggingId && draggingItem && placeholderIndex === decoratedBlocks.length}
|
||||
<div role="listitem" use:trackRow={draggingItem.block.id}>
|
||||
<BlockCard
|
||||
block={draggingItem.block}
|
||||
index={placeholderIndex - 1}
|
||||
femaleIndex={draggingItem.femaleIndex}
|
||||
maleIndex={draggingItem.maleIndex}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
onHandlePointerDown={beginPointerDrag(draggingItem.block.id)}
|
||||
forceCollapse={collapseAll}
|
||||
{colorizeTitles}
|
||||
isDragging={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="add-bottom">
|
||||
<div class="quick-add">
|
||||
<button type="button" class="btn icon-only" title="Баннер с ссылкой" on:click={() => onAdd && onAdd("banner")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="2.5" y="3.5" width="11" height="9" rx="1.4" />
|
||||
<path d="M3.5 5.5h9" />
|
||||
<path d="M4.3 10.5 6.6 8l2.1 2.3 1.5-1.3 1.5 1.5" />
|
||||
<circle cx="5.4" cy="6.8" r="0.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn icon-only" title="Текстовый блок" on:click={() => onAdd && onAdd("paragraph")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 4.5h8" />
|
||||
<path d="M8 4.5v7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn icon-only" title="4 товара в ряд" on:click={() => onAdd && onAdd("products4Row")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="2" y="6" width="2.8" height="4" rx="0.5" />
|
||||
<rect x="5" y="6" width="2.8" height="4" rx="0.5" />
|
||||
<rect x="8" y="6" width="2.8" height="4" rx="0.5" />
|
||||
<rect x="11" y="6" width="2.8" height="4" rx="0.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn icon-only" title="3 товара + картинка слева" on:click={() => onAdd && onAdd("productsImageLeft3")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="2" y="3" width="5.5" height="10" rx="0.8" />
|
||||
<rect x="9" y="3.5" width="3" height="2.5" rx="0.5" />
|
||||
<rect x="9" y="6.5" width="3" height="2.5" rx="0.5" />
|
||||
<rect x="9" y="9.5" width="3" height="2.5" rx="0.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn icon-only" title="3 товара + картинка справа" on:click={() => onAdd && onAdd("productsImageRight3")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="9" y="3" width="5.5" height="10" rx="0.8" />
|
||||
<rect x="4" y="3.5" width="3" height="2.5" rx="0.5" />
|
||||
<rect x="4" y="6.5" width="3" height="2.5" rx="0.5" />
|
||||
<rect x="4" y="9.5" width="3" height="2.5" rx="0.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn icon-only" title="Кнопка по центру" on:click={() => onAdd && onAdd("buttonSingle")}>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="3" y="6.5" width="10" height="3" rx="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
class="btn add-bottom-select"
|
||||
bind:value={addValue}
|
||||
on:change={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val && onAdd) onAdd(val);
|
||||
addValue = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ Добавить блок</option>
|
||||
{#each blockGroups as group}
|
||||
<optgroup label={group.label}>
|
||||
{#each group.options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.add-bottom {
|
||||
padding: 12px 4px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.add-bottom-select {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
appearance: none;
|
||||
text-align: center;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-add .btn.icon-only {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.quick-add .btn.icon-only .icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-add .btn.icon-only .icon rect,
|
||||
.quick-add .btn.icon-only .icon path {
|
||||
stroke: currentColor;
|
||||
stroke-width: 0.6;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.quick-add .btn.icon-only:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--pill);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
1
aspekter_ref/editor-svelte/src/components/aspekter.svg
Normal file
1
aspekter_ref/editor-svelte/src/components/aspekter.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Ссылка (href)
|
||||
<input type="text" value={block.data.href ?? ""} on:input={(e) => update({ href: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Имя файла изображения (без расширения)
|
||||
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Расширение файла
|
||||
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Имя файла (без расширения)
|
||||
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Расширение файла
|
||||
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Высота баннера (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 293}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>Отступ сверху (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.topSpacing ?? 40}
|
||||
on:input={(e) => update({ topSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 0}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
let showAdv = false;
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
</script>
|
||||
|
||||
<label>Левая кнопка — текст
|
||||
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
|
||||
</label>
|
||||
<label>Левая кнопка — ссылка
|
||||
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Правая кнопка — текст
|
||||
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
|
||||
</label>
|
||||
<label>Правая кнопка — ссылка
|
||||
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Ширина (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 275}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Высота (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 45}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Отступ между кнопками (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.gap ?? 20}
|
||||
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Цвет фона (#hex)
|
||||
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
|
||||
</label>
|
||||
<label>Цвет текста (#hex)
|
||||
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
|
||||
</label>
|
||||
<label>Размер шрифта (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.fontSize ?? 16}
|
||||
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
let showAdv = false;
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
</script>
|
||||
|
||||
<label>
|
||||
Текст кнопки
|
||||
<input type="text" value={block.data.text ?? ""} on:input={(e) => update({ text: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Ссылка (href)
|
||||
<input type="text" value={block.data.href ?? ""} on:input={(e) => update({ href: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Ширина (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 340}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Высота (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 45}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Цвет фона (#hex)
|
||||
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Цвет текста (#hex)
|
||||
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Размер шрифта (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.fontSize ?? 16}
|
||||
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
let showAdv = false;
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
</script>
|
||||
|
||||
<label>Левая кнопка — текст
|
||||
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
|
||||
</label>
|
||||
<label>Левая кнопка — ссылка
|
||||
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Средняя кнопка — текст
|
||||
<input type="text" value={block.data.centerText ?? ""} on:input={(e) => update({ centerText: e.target.value })} />
|
||||
</label>
|
||||
<label>Средняя кнопка — ссылка
|
||||
<input type="text" value={block.data.centerHref ?? ""} on:input={(e) => update({ centerHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Правая кнопка — текст
|
||||
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
|
||||
</label>
|
||||
<label>Правая кнопка — ссылка
|
||||
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Ширина (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 174}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Высота (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 45}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Отступ между кнопками (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.gap ?? 24}
|
||||
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>Цвет фона (#hex)
|
||||
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
|
||||
</label>
|
||||
<label>Цвет текста (#hex)
|
||||
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
|
||||
</label>
|
||||
<label>Размер шрифта (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.fontSize ?? 16}
|
||||
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Ширина (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 300}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Высота (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 1}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.swapCenter}
|
||||
on:change={(e) => update({ swapCenter: e.target.checked })}
|
||||
/>
|
||||
Использовать как центральный разделитель
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,419 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
let textareaEl;
|
||||
let showPreview = false;
|
||||
let showAdv = false;
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let measureCtx;
|
||||
|
||||
const PREPOSITIONS = [
|
||||
"в",
|
||||
"во",
|
||||
"без",
|
||||
"до",
|
||||
"для",
|
||||
"за",
|
||||
"из",
|
||||
"изо",
|
||||
"к",
|
||||
"ко",
|
||||
"на",
|
||||
"над",
|
||||
"о",
|
||||
"об",
|
||||
"обо",
|
||||
"от",
|
||||
"ото",
|
||||
"по",
|
||||
"под",
|
||||
"подо",
|
||||
"при",
|
||||
"про",
|
||||
"ради",
|
||||
"с",
|
||||
"со",
|
||||
"у",
|
||||
"через",
|
||||
"черезо",
|
||||
"между",
|
||||
"перед",
|
||||
"пред",
|
||||
"около",
|
||||
"после",
|
||||
"вне"
|
||||
];
|
||||
const PARTICLES = ["не"];
|
||||
const CONJUNCTIONS = ["и", "а", "но", "да", "или"];
|
||||
const HANG_BREAK = ["и", "а", "но"]; // только короткие союзы для переноса строки
|
||||
const HANG_WORDS = [...CONJUNCTIONS, ...PARTICLES, ...PREPOSITIONS];
|
||||
|
||||
function ensureMeasureCtx() {
|
||||
if (!measureCtx) {
|
||||
const canvas = document.createElement("canvas");
|
||||
measureCtx = canvas.getContext("2d");
|
||||
}
|
||||
measureCtx.font = "18px Helvetica, Arial, sans-serif";
|
||||
}
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textareaEl) return;
|
||||
textareaEl.style.height = "auto";
|
||||
textareaEl.style.height = `${textareaEl.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
const ta = e.target;
|
||||
const raw = ta.value;
|
||||
const newlineBeforePos = (raw.slice(0, ta.selectionStart ?? raw.length).match(/\n/g) || []).length;
|
||||
const normalized = raw.replace(/\r?\n/g, "<br>");
|
||||
if (normalized !== raw) {
|
||||
ta.value = normalized;
|
||||
const pos = (ta.selectionStart ?? normalized.length) + newlineBeforePos * 3;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
}
|
||||
update({ text: normalized });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function insertBr() {
|
||||
if (!textareaEl) return;
|
||||
const ta = textareaEl;
|
||||
const insert = "<br>";
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
const pos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
ta.focus();
|
||||
update({ text: ta.value });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function insertMdash() {
|
||||
if (!textareaEl) return;
|
||||
const ta = textareaEl;
|
||||
const insert = "—";
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
const pos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
ta.focus();
|
||||
update({ text: ta.value });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function makeBold() {
|
||||
if (!textareaEl) return;
|
||||
const ta = textareaEl;
|
||||
const start = ta.selectionStart ?? 0;
|
||||
const end = ta.selectionEnd ?? 0;
|
||||
const before = ta.value.slice(0, start);
|
||||
const selected = ta.value.slice(start, end);
|
||||
const after = ta.value.slice(end);
|
||||
const open = '<span style="font-weight:700;">';
|
||||
const close = "</span>";
|
||||
let next;
|
||||
|
||||
if (start !== end) {
|
||||
next = before + open + selected + close + after;
|
||||
ta.value = next;
|
||||
ta.selectionStart = start + open.length;
|
||||
ta.selectionEnd = start + open.length + selected.length;
|
||||
} else {
|
||||
const insert = open + close;
|
||||
next = before + insert + after;
|
||||
ta.value = next;
|
||||
const pos = start + open.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
}
|
||||
ta.focus();
|
||||
update({ text: ta.value });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function applyTypograph() {
|
||||
if (!textareaEl) return;
|
||||
const ta = textareaEl;
|
||||
let text = ta.value;
|
||||
|
||||
text = text
|
||||
.replace(/\.\.\./g, "…")
|
||||
.replace(/(^|[\s(])"([^"]+)"/g, '$1«$2»')
|
||||
.replace(/--/g, "—")
|
||||
.replace(/\s-\s/g, " — ")
|
||||
.replace(
|
||||
new RegExp(`(^|[\\s(])(${[...PREPOSITIONS, ...PARTICLES, ...CONJUNCTIONS].join("|")})\\s+`, "giu"),
|
||||
(_, prefix, word) => `${prefix}${word} `
|
||||
)
|
||||
.replace(/\u00A0\s+/g, "\u00A0")
|
||||
.replace(/ {2,}/g, " ")
|
||||
.replace(/\s+,/g, ",")
|
||||
.replace(/\s+\./g, ".")
|
||||
.replace(/\s+!/g, "!")
|
||||
.replace(/\s+\?/g, "?")
|
||||
.replace(/\s+:/g, ":");
|
||||
|
||||
ta.value = text;
|
||||
const pos = ta.selectionEnd ?? text.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
ta.focus();
|
||||
update({ text });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function autoWrapBr() {
|
||||
if (!textareaEl) return;
|
||||
ensureMeasureCtx();
|
||||
|
||||
const NBSP = "\u00A0";
|
||||
const limit = 520; // preview width minus padding
|
||||
const spaceWidth = measureCtx.measureText(" ").width;
|
||||
|
||||
const raw = textareaEl.value
|
||||
.replace(/ /g, NBSP)
|
||||
.replace(/\u00A0\s+/g, NBSP)
|
||||
.replace(/ {2,}/g, " ")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const tokens = raw.split(/(<[^>]+>)/).filter(Boolean);
|
||||
const lines = [];
|
||||
const linesTokens = [];
|
||||
let currentTokens = [];
|
||||
let currentWidth = 0;
|
||||
|
||||
const pushLine = () => {
|
||||
const lineStr = currentTokens.join("").trimEnd().replace(new RegExp(NBSP, "g"), " ");
|
||||
lines.push(lineStr);
|
||||
linesTokens.push([...currentTokens]);
|
||||
currentTokens = [];
|
||||
currentWidth = 0;
|
||||
};
|
||||
|
||||
const isTag = (t) => /^<[^>]+>$/.test(t);
|
||||
const hasTrailingSpace = () => {
|
||||
for (let i = currentTokens.length - 1; i >= 0; i--) {
|
||||
const t = currentTokens[i];
|
||||
if (isTag(t)) continue;
|
||||
return /[ \u00A0]$/.test(t);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getLastWordToken = () => {
|
||||
for (let i = currentTokens.length - 1; i >= 0; i--) {
|
||||
const t = currentTokens[i];
|
||||
if (isTag(t)) continue;
|
||||
const trimmed = t.trim();
|
||||
if (trimmed) {
|
||||
return { index: i, token: t };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const isBr = /^<\s*br\s*\/?\s*>$/i.test(token);
|
||||
if (isBr) {
|
||||
if (currentTokens.length) pushLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTag(token)) {
|
||||
currentTokens.push(token);
|
||||
return;
|
||||
}
|
||||
|
||||
const textChunk = token;
|
||||
// Разбиваем только по обычным пробелам/переводам строк, NBSP остаётся внутри слов
|
||||
const words = textChunk.split(/[ \t\r\n]+/).filter(Boolean);
|
||||
|
||||
words.forEach((word) => {
|
||||
const plain = word.replace(new RegExp(NBSP, "g"), " ");
|
||||
const wordWidth = measureCtx.measureText(plain).width;
|
||||
|
||||
// Знаки препинания присоединяем к предыдущему слову без пробела
|
||||
const isPunct = /^[-–—!?.,:;]+$/.test(plain.trim());
|
||||
if (isPunct) {
|
||||
if (currentTokens.length) {
|
||||
currentTokens.push(word);
|
||||
currentWidth += wordWidth;
|
||||
return;
|
||||
}
|
||||
if (lines.length) {
|
||||
const last = lines.pop() || "";
|
||||
lines.push((last + word).replace(new RegExp(NBSP, "g"), " "));
|
||||
return;
|
||||
}
|
||||
currentTokens.push(word);
|
||||
currentWidth += wordWidth;
|
||||
return;
|
||||
}
|
||||
|
||||
const needSpace = currentTokens.length > 0 && !hasTrailingSpace();
|
||||
const nextWidth = currentWidth + (needSpace ? spaceWidth : 0) + wordWidth;
|
||||
|
||||
if (nextWidth > limit && currentTokens.length) {
|
||||
// если последним словом был висячий — переносим его на новую строку
|
||||
const lastWord = getLastWordToken();
|
||||
let carried = null;
|
||||
if (lastWord) {
|
||||
const lastPlain = lastWord.token.replace(new RegExp(NBSP, "g"), " ").trim().toLowerCase();
|
||||
if (HANG_WORDS.includes(lastPlain)) {
|
||||
// убираем последний токен и возможный пробел перед ним
|
||||
currentTokens.splice(lastWord.index, 1);
|
||||
currentWidth -= measureCtx.measureText(lastPlain).width;
|
||||
if (currentTokens.length && currentTokens[currentTokens.length - 1] === " ") {
|
||||
currentTokens.pop();
|
||||
currentWidth -= spaceWidth;
|
||||
}
|
||||
carried = lastWord.token;
|
||||
}
|
||||
}
|
||||
|
||||
pushLine();
|
||||
|
||||
if (carried) {
|
||||
currentTokens.push(carried);
|
||||
currentWidth += measureCtx.measureText(
|
||||
carried.replace(new RegExp(NBSP, "g"), " ")
|
||||
).width;
|
||||
}
|
||||
}
|
||||
|
||||
if (needSpace && currentTokens.length) {
|
||||
currentTokens.push(" ");
|
||||
currentWidth += spaceWidth;
|
||||
}
|
||||
|
||||
currentTokens.push(word);
|
||||
currentWidth += wordWidth;
|
||||
});
|
||||
});
|
||||
|
||||
if (currentTokens.length) pushLine();
|
||||
|
||||
const countWords = (tokens) =>
|
||||
tokens.reduce((acc, t) => {
|
||||
if (!isTag(t) && t.trim()) return acc + 1;
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
const popLastWord = (tokens) => {
|
||||
let collected = [];
|
||||
while (tokens.length) {
|
||||
const t = tokens.pop();
|
||||
if (isTag(t)) {
|
||||
collected.unshift(t);
|
||||
continue;
|
||||
}
|
||||
if (!t.trim()) {
|
||||
continue;
|
||||
}
|
||||
collected.unshift(t);
|
||||
break;
|
||||
}
|
||||
while (tokens.length && !tokens[tokens.length - 1].trim()) {
|
||||
tokens.pop();
|
||||
}
|
||||
return collected.length ? collected : null;
|
||||
};
|
||||
|
||||
if (linesTokens.length >= 2) {
|
||||
const lastTokens = linesTokens[linesTokens.length - 1];
|
||||
const prevTokens = linesTokens[linesTokens.length - 2];
|
||||
|
||||
if (countWords(lastTokens) === 1 && countWords(prevTokens) > 1) {
|
||||
const moved = popLastWord(prevTokens);
|
||||
if (moved) {
|
||||
if (prevTokens.length && !/[ \u00A0]$/.test(prevTokens[prevTokens.length - 1] || "")) {
|
||||
moved.unshift(" ");
|
||||
}
|
||||
lastTokens.unshift(...moved);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < linesTokens.length; i++) {
|
||||
lines[i] = linesTokens[i].join("").trimEnd().replace(new RegExp(NBSP, "g"), " ");
|
||||
}
|
||||
}
|
||||
|
||||
const wrapped = lines.join("<br>");
|
||||
const clean = wrapped.replace(/\s*<br\s*\/?>\s*/gi, "<br>");
|
||||
|
||||
textareaEl.value = clean;
|
||||
const pos = textareaEl.value.length;
|
||||
textareaEl.selectionStart = textareaEl.selectionEnd = pos;
|
||||
textareaEl.focus();
|
||||
update({ text: clean });
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resizeTextarea();
|
||||
});
|
||||
</script>
|
||||
|
||||
<label>
|
||||
Текст блока
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="paragraph-textarea"
|
||||
value={block.data.text ?? ""}
|
||||
on:input={handleInput}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="btn-inline-row">
|
||||
<button class="btn-inline" type="button" on:click={insertBr}><br></button>
|
||||
<button class="btn-inline" type="button" on:click={makeBold}>Жирный</button>
|
||||
<button class="btn-inline" type="button" on:click={insertMdash}>—</button>
|
||||
<button class="btn-inline" type="button" on:click={applyTypograph}>Типограф</button>
|
||||
<button class="btn-inline" type="button" on:click={autoWrapBr}>Авто <br></button>
|
||||
<button
|
||||
class="btn-inline push-right"
|
||||
class:active={showPreview}
|
||||
type="button"
|
||||
aria-pressed={showPreview}
|
||||
on:click={() => (showPreview = !showPreview)}
|
||||
>
|
||||
HTML
|
||||
</button>
|
||||
</div>
|
||||
{#if showPreview}
|
||||
<div class="block-preview paragraph-preview" aria-live="polite">
|
||||
{@html block.data.text || ""}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>ID товаров (через запятую)
|
||||
<input type="text" value={block.data.productIds ?? ""} on:input={(e) => update({ productIds: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ссылка на картинку (href)
|
||||
<input type="text" value={block.data.link ?? ""} on:input={(e) => update({ link: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Имя файла картинки
|
||||
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
|
||||
</label>
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Расширение файла
|
||||
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
|
||||
</label>
|
||||
<label>Ширина картинки (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.imgWidth ?? 275}
|
||||
on:input={(e) => update({ imgWidth: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>ID товаров (через запятую)
|
||||
<input type="text" value={block.data.productIds ?? ""} on:input={(e) => update({ productIds: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Промокод
|
||||
<input type="text" value={block.data.code ?? ""} on:input={(e) => update({ code: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Размеры (через запятую)
|
||||
<input type="text" value={block.data.sizes ?? ""} on:input={(e) => update({ sizes: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ссылки (через запятую)
|
||||
<input type="text" value={block.data.links ?? ""} on:input={(e) => update({ links: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 20}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
</script>
|
||||
|
||||
<label>
|
||||
Высота (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.height ?? 40}
|
||||
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Ссылка (href)
|
||||
<input type="text" value={block.data.link ?? ""} on:input={(e) => update({ link: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Имя файла картинки
|
||||
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
|
||||
</label>
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Расширение файла
|
||||
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ширина картинки (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.imgWidth ?? 264}
|
||||
on:input={(e) => update({ imgWidth: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>Высота картинки (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.imgHeight ?? 330}
|
||||
on:input={(e) => update({ imgHeight: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 20}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label>Заголовок внутри блока
|
||||
<input type="text" value={block.data.header ?? ""} on:input={(e) => update({ header: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Текст внутри блока
|
||||
<textarea value={block.data.text ?? ""} on:input={(e) => update({ text: e.target.value })}></textarea>
|
||||
</label>
|
||||
|
||||
<label>Текст кнопки
|
||||
<input type="text" value={block.data.buttonText ?? ""} on:input={(e) => update({ buttonText: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ссылка кнопки
|
||||
<input type="text" value={block.data.buttonHref ?? ""} on:input={(e) => update({ buttonHref: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Баннер 1 — ссылка
|
||||
<input type="text" value={block.data.href1 ?? ""} on:input={(e) => update({ href1: e.target.value })} />
|
||||
</label>
|
||||
<label>Баннер 1 — имя файла
|
||||
<input type="text" value={block.data.imgBaseName1 ?? ""} on:input={(e) => update({ imgBaseName1: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Баннер 2 — ссылка
|
||||
<input type="text" value={block.data.href2 ?? ""} on:input={(e) => update({ href2: e.target.value })} />
|
||||
</label>
|
||||
<label>Баннер 2 — имя файла
|
||||
<input type="text" value={block.data.imgBaseName2 ?? ""} on:input={(e) => update({ imgBaseName2: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Баннер 3 — ссылка
|
||||
<input type="text" value={block.data.href3 ?? ""} on:input={(e) => update({ href3: e.target.value })} />
|
||||
</label>
|
||||
<label>Баннер 3 — имя файла
|
||||
<input type="text" value={block.data.imgBaseName3 ?? ""} on:input={(e) => update({ imgBaseName3: e.target.value })} />
|
||||
</label>
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Баннер 1 — расширение
|
||||
<input type="text" value={block.data.imgExtension1 ?? ".png"} on:input={(e) => update({ imgExtension1: e.target.value })} />
|
||||
</label>
|
||||
<label>Баннер 2 — расширение
|
||||
<input type="text" value={block.data.imgExtension2 ?? ".png"} on:input={(e) => update({ imgExtension2: e.target.value })} />
|
||||
</label>
|
||||
<label>Баннер 3 — расширение
|
||||
<input type="text" value={block.data.imgExtension3 ?? ".png"} on:input={(e) => update({ imgExtension3: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ширина баннеров (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 170}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>Отступ между баннерами (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.gap ?? 30}
|
||||
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
let textareaEl;
|
||||
let showAdv = false;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
|
||||
function insertBr() {
|
||||
if (!textareaEl) return;
|
||||
const ta = textareaEl;
|
||||
const insert = "<br>";
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
const pos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
ta.focus();
|
||||
update({ text: ta.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<label>
|
||||
Текст актуального заголовка
|
||||
<textarea
|
||||
class="singleline-textarea"
|
||||
rows="1"
|
||||
bind:this={textareaEl}
|
||||
value={block.data.text ?? ""}
|
||||
on:input={(e) => update({ text: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button class="btn-inline" type="button" on:click={insertBr}><br></button>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
bind:value={block.data.bottomSpacing}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Левый баннер — ссылка
|
||||
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Левый баннер — имя файла
|
||||
<input type="text" value={block.data.leftImageBaseName ?? ""} on:input={(e) => update({ leftImageBaseName: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Правый баннер — ссылка
|
||||
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Правый баннер — имя файла
|
||||
<input type="text" value={block.data.rightImageBaseName ?? ""} on:input={(e) => update({ rightImageBaseName: e.target.value })} />
|
||||
</label>
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Левый баннер — расширение
|
||||
<input type="text" value={block.data.leftImageExtension ?? ".png"} on:input={(e) => update({ leftImageExtension: e.target.value })} />
|
||||
</label>
|
||||
<label>Правый баннер — расширение
|
||||
<input type="text" value={block.data.rightImageExtension ?? ".png"} on:input={(e) => update({ rightImageExtension: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ширина баннеров (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 270}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>Отступ между баннерами (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.gap ?? 30}
|
||||
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
export let block;
|
||||
export let onChange;
|
||||
|
||||
const update = (patch) => onChange({ ...block.data, ...patch });
|
||||
let showAdv = false;
|
||||
</script>
|
||||
|
||||
<label>Левый баннер — ссылка
|
||||
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Левый баннер — имя файла
|
||||
<input type="text" value={block.data.leftImageBaseName ?? ""} on:input={(e) => update({ leftImageBaseName: e.target.value })} />
|
||||
</label>
|
||||
<label>Левый баннер — текст
|
||||
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Правый баннер — ссылка
|
||||
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
|
||||
</label>
|
||||
<label>Правый баннер — имя файла
|
||||
<input type="text" value={block.data.rightImageBaseName ?? ""} on:input={(e) => update({ rightImageBaseName: e.target.value })} />
|
||||
</label>
|
||||
<label>Правый баннер — текст
|
||||
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<div class="advanced-wrapper">
|
||||
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
|
||||
Доп. настройки
|
||||
</button>
|
||||
{#if showAdv}
|
||||
<div class="advanced-panel">
|
||||
<label>Левый баннер — расширение
|
||||
<input type="text" value={block.data.leftImageExtension ?? ".png"} on:input={(e) => update({ leftImageExtension: e.target.value })} />
|
||||
</label>
|
||||
<label>Правый баннер — расширение
|
||||
<input type="text" value={block.data.rightImageExtension ?? ".png"} on:input={(e) => update({ rightImageExtension: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<label>Ширина баннеров (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.width ?? 270}
|
||||
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>Отступ между баннерами (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.gap ?? 30}
|
||||
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Отступ снизу (px)
|
||||
<input
|
||||
type="number"
|
||||
value={block.data.bottomSpacing ?? 40}
|
||||
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.data.removeBottomSpacing}
|
||||
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
|
||||
/>
|
||||
Убрать отступ после блока
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
613
aspekter_ref/editor-svelte/src/lib/pug.js
Normal file
613
aspekter_ref/editor-svelte/src/lib/pug.js
Normal file
@@ -0,0 +1,613 @@
|
||||
export function buildImageUrl(baseName, ext, fallbackUrl, base) {
|
||||
if (baseName) {
|
||||
const safeBase = base ? base.replace(/\/?$/, "/") : "";
|
||||
const safeExt = ext && ext.trim() ? ext.trim() : ".png";
|
||||
const finalExt = safeExt.startsWith(".") ? safeExt : "." + safeExt;
|
||||
return safeBase + baseName + finalExt;
|
||||
}
|
||||
if (fallbackUrl) return fallbackUrl;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function renderBlockToPug(block, settings) {
|
||||
const d = block.data || {};
|
||||
|
||||
switch (block.type) {
|
||||
case "spacer": {
|
||||
const h = d.height ?? 40;
|
||||
return `+spacerLine(${h})`;
|
||||
}
|
||||
|
||||
case "titleActual": {
|
||||
const text = d.text || "";
|
||||
const top = d.topSpacing ?? 40;
|
||||
const bottom = d.bottomSpacing ?? 20;
|
||||
const addTop = !d.removeTopSpacing;
|
||||
const addBottom = !d.removeBottomSpacing;
|
||||
|
||||
let res = "";
|
||||
if (addTop) {
|
||||
res += `+spacerLine(${top})\n`;
|
||||
}
|
||||
|
||||
res +=
|
||||
"tr\n" +
|
||||
" td.paddingWrapper \n" +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr \n" +
|
||||
' td(align="center")\n' +
|
||||
` span.font.h2.blackText.uppercase ${text}`;
|
||||
|
||||
if (addBottom) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "paragraph": {
|
||||
const text = d.text || "";
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"tr \n" +
|
||||
" td.paddingWrapper \n" +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr \n" +
|
||||
' td(align="center")\n' +
|
||||
` span.font.h3.blackText ${text}`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "buttonSingle": {
|
||||
const t = d.text || "";
|
||||
const href = d.href || "#";
|
||||
const w = d.width ?? 340;
|
||||
const h = d.height ?? 45;
|
||||
const bg = d.bgColor || "#242424";
|
||||
const fs = d.fontSize ?? 16;
|
||||
const color = d.textColor || "#ffffff";
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"////Блок с кнопкой посередине\n" +
|
||||
"tr\n" +
|
||||
" td.headerWrapper\n" +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr\n" +
|
||||
' td(align="center")\n' +
|
||||
` +buttonRounded("${t}", "${href}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "buttonDouble": {
|
||||
const {
|
||||
leftText,
|
||||
leftHref,
|
||||
rightText,
|
||||
rightHref,
|
||||
width,
|
||||
height,
|
||||
gap,
|
||||
bgColor,
|
||||
fontSize,
|
||||
textColor,
|
||||
bottomSpacing,
|
||||
removeBottomSpacing
|
||||
} = d;
|
||||
|
||||
const w = width ?? 275;
|
||||
const h = height ?? 45;
|
||||
const g = gap ?? 20;
|
||||
const bg = bgColor || "#242424";
|
||||
const fs = fontSize ?? 16;
|
||||
const color = textColor || "#ffffff";
|
||||
const bottom = bottomSpacing ?? 40;
|
||||
const addSpacing = !removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"//Блок с 2мя кнопками\n\n" +
|
||||
"tr \n" +
|
||||
" td.paddingWrapper\n" +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr \n" +
|
||||
' td(align="right") \n' +
|
||||
` +buttonRounded("${leftText || ""}", "${leftHref ||
|
||||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
' td(align="left") \n' +
|
||||
` +buttonRounded("${rightText || ""}", "${rightHref ||
|
||||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "buttonTriple": {
|
||||
const {
|
||||
leftText,
|
||||
leftHref,
|
||||
centerText,
|
||||
centerHref,
|
||||
rightText,
|
||||
rightHref,
|
||||
width,
|
||||
height,
|
||||
gap,
|
||||
bgColor,
|
||||
fontSize,
|
||||
textColor,
|
||||
bottomSpacing,
|
||||
removeBottomSpacing
|
||||
} = d;
|
||||
|
||||
const w = width ?? 174;
|
||||
const h = height ?? 45;
|
||||
const g = gap ?? 24;
|
||||
const bg = bgColor || "#242424";
|
||||
const fs = fontSize ?? 16;
|
||||
const color = textColor || "#ffffff";
|
||||
const bottom = bottomSpacing ?? 40;
|
||||
const addSpacing = !removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"//Блок с 3мя кнопками\n\n" +
|
||||
"tr \n" +
|
||||
" td.paddingWrapper \n" +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
` +buttonRounded("${leftText || ""}", "${leftHref ||
|
||||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +buttonRounded("${centerText || ""}", "${centerHref ||
|
||||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +buttonRounded("${rightText || ""}", "${rightHref ||
|
||||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "banner": {
|
||||
const href = d.href || "#";
|
||||
const baseName = d.imageBaseName || "";
|
||||
const ext = d.imageExtension || ".png";
|
||||
|
||||
let img = "";
|
||||
if (baseName) {
|
||||
img = buildImageUrl(baseName, ext, d.imageUrl, settings.imageBaseUrl);
|
||||
} else if (d.imageUrl) {
|
||||
img = d.imageUrl;
|
||||
}
|
||||
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"////Блок с баннером и ссылкой\n" +
|
||||
`+bannerWLink("${href}", "${img}")`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "bannerNoLink": {
|
||||
const img = buildImageUrl(
|
||||
d.imageBaseName || "",
|
||||
d.imageExtension || ".png",
|
||||
d.imageUrl,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const h = d.height ?? 293;
|
||||
const top = d.topSpacing ?? 40;
|
||||
const bottom = d.bottomSpacing ?? 0;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res = `+spacerLine(${top})\n+bannerWithoutLink("${img}", ${h})`;
|
||||
|
||||
if (addSpacing && bottom > 0) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "twoBannersWithText": {
|
||||
const {
|
||||
leftHref,
|
||||
leftImage,
|
||||
leftImageBaseName,
|
||||
leftImageExtension,
|
||||
leftText,
|
||||
rightHref,
|
||||
rightImage,
|
||||
rightImageBaseName,
|
||||
rightImageExtension,
|
||||
rightText,
|
||||
width,
|
||||
gap,
|
||||
bottomSpacing,
|
||||
removeBottomSpacing
|
||||
} = d;
|
||||
|
||||
const leftImg = buildImageUrl(
|
||||
leftImageBaseName || "",
|
||||
leftImageExtension || ".png",
|
||||
leftImage,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
|
||||
const rightImg = buildImageUrl(
|
||||
rightImageBaseName || "",
|
||||
rightImageExtension || ".png",
|
||||
rightImage,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
|
||||
const w = width ?? 270;
|
||||
const g = gap ?? 30;
|
||||
const bottom = bottomSpacing ?? 40;
|
||||
const addSpacing = !removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"tr \n" +
|
||||
' td(align="center").paddingWrapper\n' +
|
||||
' +defaultTable("", "center")\n' +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
" //- (Ссылка, изображение, ширина картинки = 270, текст под баннером)\n" +
|
||||
` +bannerWithLink("${leftHref ||
|
||||
"#"}", "${leftImg || ""}", ${w}, "${leftText || ""}")\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +bannerWithLink("${rightHref ||
|
||||
"#"}", "${rightImg || ""}", ${w}, "${rightText || ""}")`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "twoBannersNoText": {
|
||||
const {
|
||||
leftHref,
|
||||
leftImage,
|
||||
leftImageBaseName,
|
||||
leftImageExtension,
|
||||
rightHref,
|
||||
rightImage,
|
||||
rightImageBaseName,
|
||||
rightImageExtension,
|
||||
width,
|
||||
gap,
|
||||
bottomSpacing,
|
||||
removeBottomSpacing
|
||||
} = d;
|
||||
|
||||
const leftImg = buildImageUrl(
|
||||
leftImageBaseName || "",
|
||||
leftImageExtension || ".jpg",
|
||||
leftImage,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const rightImg = buildImageUrl(
|
||||
rightImageBaseName || "",
|
||||
rightImageExtension || ".jpg",
|
||||
rightImage,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
|
||||
const w = width ?? 270;
|
||||
const g = gap ?? 30;
|
||||
const bottom = bottomSpacing ?? 40;
|
||||
const addSpacing = !removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"tr \n" +
|
||||
' td(align="center").paddingWrapper\n' +
|
||||
' +defaultTable("", "center")\n' +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
" //- (Ссылка, изображение, ширина изображения = 270)\n" +
|
||||
` +bannerWithLink("${leftHref ||
|
||||
"#"}", "${leftImg || ""}", ${w})\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +bannerWithLink("${rightHref ||
|
||||
"#"}", "${rightImg || ""}", ${w})`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "threeBannersNoText": {
|
||||
const {
|
||||
href1,
|
||||
img1,
|
||||
imgBaseName1,
|
||||
imgExtension1,
|
||||
href2,
|
||||
img2,
|
||||
imgBaseName2,
|
||||
imgExtension2,
|
||||
href3,
|
||||
img3,
|
||||
imgBaseName3,
|
||||
imgExtension3,
|
||||
width,
|
||||
gap,
|
||||
bottomSpacing,
|
||||
removeBottomSpacing
|
||||
} = d;
|
||||
|
||||
const imgOne = buildImageUrl(
|
||||
imgBaseName1 || "",
|
||||
imgExtension1 || ".png",
|
||||
img1,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const imgTwo = buildImageUrl(
|
||||
imgBaseName2 || "",
|
||||
imgExtension2 || ".png",
|
||||
img2,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const imgThree = buildImageUrl(
|
||||
imgBaseName3 || "",
|
||||
imgExtension3 || ".png",
|
||||
img3,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
|
||||
const w = width ?? 170;
|
||||
const g = gap ?? 30;
|
||||
const bottom = bottomSpacing ?? 40;
|
||||
const addSpacing = !removeBottomSpacing;
|
||||
|
||||
let res =
|
||||
"tr \n" +
|
||||
' td(align="center").paddingWrapper\n' +
|
||||
' +defaultTable("", "center")\n' +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
" //- (Ссылка, изображение)\n" +
|
||||
` +bannerWithLink("${href1 ||
|
||||
"#"}", "${imgOne || ""}", ${w})\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +bannerWithLink("${href2 ||
|
||||
"#"}", "${imgTwo || ""}", ${w})\n` +
|
||||
` +tdFixed(${g})\n` +
|
||||
" td \n" +
|
||||
` +bannerWithLink("${href3 ||
|
||||
"#"}", "${imgThree || ""}", ${w})`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "products4Row":
|
||||
case "products3Row": {
|
||||
const idsRaw = d.productIds || "";
|
||||
const ids = idsRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
const showPrices = settings.showPrices !== undefined ? !!settings.showPrices : d.showPrices ?? true;
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
const mixinName = block.type === "products4Row" ? "products4Row" : "products3Row";
|
||||
|
||||
let params = `"${ids}"`;
|
||||
if (!showPrices) {
|
||||
params += ", {showPrices : false}";
|
||||
}
|
||||
|
||||
let res = `+${mixinName}(${params})`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "productsImageLeft":
|
||||
case "productsImageRight":
|
||||
case "productsImageLeft3":
|
||||
case "productsImageRight3": {
|
||||
const idsRaw = d.productIds || "";
|
||||
const ids = idsRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
const link = d.link || "#";
|
||||
const img = buildImageUrl(
|
||||
d.imageBaseName || "",
|
||||
d.imageExtension || ".png",
|
||||
d.imageUrl,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const w = d.imgWidth ?? 275;
|
||||
const isThree = block.type === "productsImageLeft3" || block.type === "productsImageRight3";
|
||||
const showPrices =
|
||||
settings.showPrices !== undefined ? !!settings.showPrices : d.showPrices ?? true;
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let mixinName;
|
||||
if (block.type === "productsImageLeft" || block.type === "productsImageLeft3") {
|
||||
mixinName = isThree ? "productsColumnImageLeft" : "productsImageLeft";
|
||||
} else {
|
||||
mixinName = isThree ? "productsColumnImageRight" : "productsImageRight";
|
||||
}
|
||||
|
||||
let options = showPrices === false ? ", {showPrices : false}" : "";
|
||||
|
||||
let res;
|
||||
if (isThree) {
|
||||
res = `+${mixinName}("${ids}", "${link}", "${img}"${options})`;
|
||||
} else {
|
||||
res = `+${mixinName}("${ids}", "${link}", "${img}", ${w}${options})`;
|
||||
}
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "textImageLeft":
|
||||
case "textImageRight": {
|
||||
const link = d.link || "#";
|
||||
const img = buildImageUrl(
|
||||
d.imageBaseName || "",
|
||||
d.imageExtension || ".png",
|
||||
d.imageUrl,
|
||||
settings.imageBaseUrl
|
||||
);
|
||||
const w = d.imgWidth ?? 264;
|
||||
const h = d.imgHeight ?? 330;
|
||||
const header = d.header || "";
|
||||
const text = d.text || "";
|
||||
const btnText = d.buttonText || "";
|
||||
const btnHref = d.buttonHref || "#";
|
||||
const bottom = d.bottomSpacing ?? 20;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
const mixinName = block.type === "textImageLeft" ? "textImageLeft" : "textImageRight";
|
||||
|
||||
let res =
|
||||
`//Текст ${block.type === "textImageLeft" ? "справа изображение слева" : "слева изображение справа"}\n` +
|
||||
`+${mixinName}("${link}", "${img}", ${w}, ${h})\n` +
|
||||
' +defaultTable("100%")\n' +
|
||||
" tr \n" +
|
||||
" td\n" +
|
||||
` span.imageSideHeader.font.bold ${header}\n` +
|
||||
" +spacerLine(18)\n" +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
` span.font.imageSideText.font ${text}\n` +
|
||||
" +spacerLine(18)\n" +
|
||||
" tr \n" +
|
||||
" td \n" +
|
||||
` +buttonRounded("${btnText}", "${btnHref}", 160, 45, "#ffffff", 16, "#000000", 3).bold.font`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "sizeGrid": {
|
||||
const sizesRaw = d.sizes || "";
|
||||
const linksRaw = d.links || "";
|
||||
|
||||
const sizesArr = sizesRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const linksArr = linksRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const sizesString = "[" + sizesArr.map((s) => s).join(", ") + "]";
|
||||
const linksString = "[" + linksArr.map((s) => `"${s}"`).join(", ") + "]";
|
||||
|
||||
const bottom = d.bottomSpacing ?? 20;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res = `+sizes(${sizesString}, ${linksString})`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "promocode": {
|
||||
const code = d.code || "";
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
|
||||
let res = `+promocode("${code}")`;
|
||||
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
case "dividerVA": {
|
||||
const w = d.width ?? 300;
|
||||
const h = d.height ?? 1;
|
||||
const bottom = d.bottomSpacing ?? 40;
|
||||
const addSpacing = !d.removeBottomSpacing;
|
||||
const top = d.topSpacing ?? 40;
|
||||
const addTop = !d.removeTopSpacing;
|
||||
|
||||
let res = "";
|
||||
if (addTop) {
|
||||
res += `+spacerLine(${top})\n`;
|
||||
}
|
||||
// divider mixin ожидает третьего необязательного аргумента, поэтому оставляем завершающую запятую
|
||||
res += `+dividerVA(${w}, ${h},)`;
|
||||
if (addSpacing) {
|
||||
res += `\n+spacerLine(${bottom})`;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
default:
|
||||
return `// TODO: неизвестный тип блока "${block.type}"`;
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePug(blocks, settings) {
|
||||
return blocks
|
||||
.map((b) => renderBlockToPug(b, settings))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
8
aspekter_ref/editor-svelte/src/main.js
Normal file
8
aspekter_ref/editor-svelte/src/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById("app")
|
||||
});
|
||||
|
||||
export default app;
|
||||
90
aspekter_ref/editor-svelte/src/store.js
Normal file
90
aspekter_ref/editor-svelte/src/store.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { generatePug } from "./lib/pug";
|
||||
|
||||
export const BLOCKS_KEY = "vip_letter_editor_blocks_v1";
|
||||
export const SETTINGS_KEY = "vip_letter_editor_settings_v1";
|
||||
export const THEME_KEY = "vip_letter_editor_theme";
|
||||
|
||||
const loadJson = (key, fallback) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed ?? fallback;
|
||||
} catch (e) {
|
||||
console.warn("Failed to load from localStorage", e);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const persist = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.warn("Failed to save to localStorage", e);
|
||||
}
|
||||
};
|
||||
|
||||
function createBlocksStore() {
|
||||
const initial = loadJson(BLOCKS_KEY, []);
|
||||
const { subscribe, update, set } = writable(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (value) => {
|
||||
persist(BLOCKS_KEY, value);
|
||||
set(value);
|
||||
},
|
||||
update: (fn) =>
|
||||
update((prev) => {
|
||||
const next = fn(prev);
|
||||
persist(BLOCKS_KEY, next);
|
||||
return next;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createSettingsStore() {
|
||||
const defaults = {
|
||||
imageBaseUrl: "",
|
||||
showPrices: true,
|
||||
projectName: "aspekter",
|
||||
templateName: "let.pug"
|
||||
};
|
||||
const saved = loadJson(SETTINGS_KEY, defaults);
|
||||
const initial = { ...defaults, ...saved };
|
||||
const { subscribe, update, set } = writable(initial);
|
||||
return {
|
||||
subscribe,
|
||||
set: (value) => {
|
||||
persist(SETTINGS_KEY, value);
|
||||
set(value);
|
||||
},
|
||||
update: (fn) =>
|
||||
update((prev) => {
|
||||
const next = fn(prev);
|
||||
persist(SETTINGS_KEY, next);
|
||||
return next;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeStore() {
|
||||
const initial = loadJson(THEME_KEY, "dark");
|
||||
const { subscribe, set } = writable(initial);
|
||||
return {
|
||||
subscribe,
|
||||
set: (value) => {
|
||||
persist(THEME_KEY, value);
|
||||
set(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const blocks = createBlocksStore();
|
||||
export const settings = createSettingsStore();
|
||||
export const theme = createThemeStore();
|
||||
|
||||
export const pugCode = derived([blocks, settings], ([$blocks, $settings]) =>
|
||||
generatePug($blocks, $settings)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user