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:
2026-04-13 11:36:39 +05:00
commit 718821fdd6
282 changed files with 64697 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

View 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` стоит на месте, если используете сборку мужской/женской версии.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

View 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>

View 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>&lt;br&gt;</code>, <code>&nbsp;</code>, <code>&mdash;</code>, жирный через <code>&lt;span style="font-weight:700;"&gt;</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>&lt;br&gt;</code>/<code>&nbsp;</code>/<code>&mdash;</code> и жирного текста.</li>
</ul>
</div>
</body>
</html>

View 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

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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>&lt;br&gt;</code>, <code>&nbsp;</code>, <code>&mdash;</code>, жирный через <code>&lt;span style="font-weight:700;"&gt;</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>&lt;br&gt;</code>/<code>&nbsp;</code>/<code>&mdash;</code> и жирного текста.</li>
</ul>
</div>
</body>
</html>

View 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}`);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = "&mdash;";
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, " &mdash; ")
.replace(
new RegExp(`(^|[\\s(])(${[...PREPOSITIONS, ...PARTICLES, ...CONJUNCTIONS].join("|")})\\s+`, "giu"),
(_, prefix, word) => `${prefix}${word}&nbsp;`
)
.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(/&nbsp;/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"), "&nbsp;");
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"), "&nbsp;"));
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"), "&nbsp;");
}
}
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}>&lt;br&gt;</button>
<button class="btn-inline" type="button" on:click={makeBold}>Жирный</button>
<button class="btn-inline" type="button" on:click={insertMdash}>&mdash;</button>
<button class="btn-inline" type="button" on:click={applyTypograph}>Типограф</button>
<button class="btn-inline" type="button" on:click={autoWrapBr}>Авто &lt;br&gt;</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}>&lt;br&gt;</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>

View File

@@ -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>

View File

@@ -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>

View 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();
}

View File

@@ -0,0 +1,8 @@
import "./app.css";
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app")
});
export default app;

View 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)
);

View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess()
};

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()]
});