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:
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)
|
||||
);
|
||||
5
aspekter_ref/editor-svelte/svelte.config.js
Normal file
5
aspekter_ref/editor-svelte/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
6
aspekter_ref/editor-svelte/vite.config.js
Normal file
6
aspekter_ref/editor-svelte/vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()]
|
||||
});
|
||||
Reference in New Issue
Block a user