commit c090bfcf472731e1fdba470a2332be3db6e89865
Author: Sergey Zotov
Date: Mon Apr 13 01:20:24 2026 +0500
Initial commit — Aspekter VA email builder
Full project: Svelte 5 frontend, Vite 7 backend API,
Pug email templates (email-gen), Docker deployment.
Co-Authored-By: Claude Opus 4.6 (1M context)
diff --git a/.env.prod.example b/.env.prod.example
new file mode 100644
index 0000000..c801c99
--- /dev/null
+++ b/.env.prod.example
@@ -0,0 +1,2 @@
+# Host port for nginx in production compose
+HTTP_PORT=80
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..016532a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+node_modules/
+dist/
+.DS_Store
+*.tar.gz
+*.zip
+z51-pug-builder/data/
+z51-pug-builder/data-dev*/
+.claude/
+.vite/
+.env
+.env.*
+!.env.*.example
+aspekter_ref/
+__MACOSX/
+Untitled-1
+Block.pug
+reaspekt.html
+test.html
+blockAT.pug
+blockCB.pug
+blockRU.pug
diff --git a/DOCKER-DEPLOY.md b/DOCKER-DEPLOY.md
new file mode 100644
index 0000000..ed71653
--- /dev/null
+++ b/DOCKER-DEPLOY.md
@@ -0,0 +1,117 @@
+# Docker deploy: z51-pug-builder + email-gen
+
+## Что есть
+
+- `docker-compose.yml` — локальный/дев запуск (builder на `localhost:5173`)
+- `docker-compose.prod.yml` — прод запуск через nginx
+
+Сервисы:
+- `builder` — Svelte-конструктор + локальный API (`/api/*`)
+- `email-gen-api` — мост к `email-gen` (рендер PUG -> HTML)
+- `nginx` (только в prod compose) — входная точка на 80 порту
+
+---
+
+## 1) Локальный запуск (dev)
+
+```bash
+cd /Users/sergeyzotov/Documents/GENERATOR_Z51
+docker compose up -d --build
+docker compose ps
+```
+
+Открыть: `http://localhost:5173`
+
+Остановить:
+```bash
+docker compose down
+```
+
+---
+
+## 2) Прод запуск (nginx + docker)
+
+1. Подготовь env:
+```bash
+cd /Users/sergeyzotov/Documents/GENERATOR_Z51
+cp .env.prod.example .env
+```
+
+2. Запусти:
+```bash
+docker compose -f docker-compose.prod.yml up -d --build
+```
+
+3. Проверка:
+```bash
+docker compose -f docker-compose.prod.yml ps
+```
+
+Открыть: `http://` (или домен, если DNS уже настроен).
+
+Остановить:
+```bash
+docker compose -f docker-compose.prod.yml down
+```
+
+---
+
+## 3) Как работает генерация HTML
+
+1. В конструкторе собирается PUG.
+2. Нажимаешь `Превью -> Обновить`.
+3. Builder вызывает `POST /api/project/:name/render-email`.
+4. Запрос уходит в `email-gen-api`.
+5. `email-gen-api`:
+ - пишет PUG в `email-gen/emails//letters/let.pug`
+ - запускает `gulp pug --project `
+ - читает `email-gen/public/index.html`
+ - возвращает HTML обратно в конструктор.
+
+Важно: в `Настройки -> Текущий проект` заполняй поле `Папка проекта в email-gen` (например `numizmat`).
+
+---
+
+## 4) Обновление email-gen без ручной пересборки всего
+
+Скрипт:
+```bash
+./deploy/scripts/update-email-gen.sh
+```
+
+Или с веткой:
+```bash
+./deploy/scripts/update-email-gen.sh main
+```
+
+Что делает скрипт:
+- `git fetch`/`git pull --ff-only` в `email-gen`
+- пересобирает и перезапускает только `email-gen-api` контейнер
+
+Это удобно, если `email-gen` обновляется через git и перезаписывается.
+
+---
+
+## 5) Логи
+
+Prod:
+```bash
+docker compose -f docker-compose.prod.yml logs -f nginx
+docker compose -f docker-compose.prod.yml logs -f builder
+docker compose -f docker-compose.prod.yml logs -f email-gen-api
+```
+
+Dev:
+```bash
+docker compose logs -f builder
+docker compose logs -f email-gen-api
+```
+
+---
+
+## 6) Данные
+
+- Данные конструктора: `z51-pug-builder/data`
+- Репозиторий генератора: `email-gen` (bind mount в контейнер)
+
+Оба каталога остаются на хосте и не теряются при пересоздании контейнеров.
diff --git a/deploy/email-gen-api/Dockerfile b/deploy/email-gen-api/Dockerfile
new file mode 100644
index 0000000..f6fc594
--- /dev/null
+++ b/deploy/email-gen-api/Dockerfile
@@ -0,0 +1,14 @@
+FROM node:20-alpine
+
+WORKDIR /app
+COPY deploy/email-gen-api/server.js /app/server.js
+COPY deploy/email-gen-api/entrypoint.sh /app/entrypoint.sh
+RUN chmod +x /app/entrypoint.sh
+
+# Copy email-gen templates into image
+COPY email-gen /workspace/email-gen
+RUN if [ -f /workspace/email-gen/package.json ]; then cd /workspace/email-gen && npm install; fi
+
+EXPOSE 8787
+
+CMD ["/app/entrypoint.sh"]
diff --git a/deploy/email-gen-api/entrypoint.sh b/deploy/email-gen-api/entrypoint.sh
new file mode 100644
index 0000000..92ebc9c
--- /dev/null
+++ b/deploy/email-gen-api/entrypoint.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+set -e
+
+EMAIL_GEN_ROOT="${EMAIL_GEN_ROOT:-/workspace/email-gen}"
+
+# Install deps inside container if needed (host node_modules may be incompatible)
+if [ -f "$EMAIL_GEN_ROOT/package.json" ]; then
+ echo "Installing email-gen dependencies..."
+ cd "$EMAIL_GEN_ROOT"
+ npm install 2>&1
+ echo "Dependencies ready."
+fi
+
+exec node /app/server.js
diff --git a/deploy/email-gen-api/server.js b/deploy/email-gen-api/server.js
new file mode 100644
index 0000000..9e5e36c
--- /dev/null
+++ b/deploy/email-gen-api/server.js
@@ -0,0 +1,197 @@
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+const { spawnSync } = require('child_process')
+
+const PORT = Number(process.env.PORT || 8787)
+const EMAIL_GEN_ROOT = path.resolve(process.env.EMAIL_GEN_ROOT || '/workspace/email-gen')
+
+function send(res, status, payload) {
+ res.statusCode = status
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(payload))
+}
+
+const MAX_BODY_SIZE = 5 * 1024 * 1024
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let size = 0
+ let data = ''
+ req.on('data', (chunk) => {
+ size += chunk.length
+ if (size <= MAX_BODY_SIZE) data += chunk
+ })
+ req.on('end', () => {
+ if (size > MAX_BODY_SIZE) return reject(new Error('payload_too_large'))
+ try {
+ resolve(data ? JSON.parse(data) : {})
+ } catch {
+ reject(new Error('invalid_json'))
+ }
+ })
+ })
+}
+
+function sanitizeProjectSlug(value) {
+ return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
+}
+
+function rewriteHtmlPug(projectDir, preheader = '', gender = 'female', genderPaths = {}) {
+ const htmlPugPath = path.resolve(projectDir, 'html.pug')
+ const safePreheader = preheader.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+ function sanitizePartPath(p, fallback) {
+ if (!p || typeof p !== 'string') return fallback
+ const clean = p.replace(/\\/g, '/').replace(/\/+/g, '/')
+ if (/\.\./.test(clean) || /[\0\r\n]/.test(clean)) return fallback
+ if (!clean.startsWith('./parts/') && !clean.startsWith('parts/')) return fallback
+ return clean
+ }
+ const headerPath = gender === 'male'
+ ? sanitizePartPath(genderPaths.headerMale, './parts/header/header-man')
+ : sanitizePartPath(genderPaths.headerFemale, './parts/header/header-woman')
+ const footerPath = gender === 'male'
+ ? sanitizePartPath(genderPaths.footerMale, './parts/footer/footer-man')
+ : sanitizePartPath(genderPaths.footerFemale, './parts/footer/footer-woman')
+ const body = [
+ 'extends layout/layout.pug',
+ '',
+ 'block header',
+ ` include ${headerPath}`,
+ 'block preheader',
+ ` +preheader("${safePreheader}")`,
+ 'block content',
+ ' include ./letters/let.pug',
+ 'block footer',
+ ` include ${footerPath}`,
+ '',
+ ].join('\n')
+ fs.writeFileSync(htmlPugPath, body, 'utf-8')
+}
+
+function ensureNpmDeps() {
+ const marker = path.resolve(EMAIL_GEN_ROOT, 'node_modules')
+ if (fs.existsSync(marker)) return
+ const install = spawnSync('npm', ['install'], {
+ cwd: EMAIL_GEN_ROOT,
+ encoding: 'utf-8',
+ shell: false,
+ })
+ if (install.status !== 0) {
+ throw new Error((install.stderr || install.stdout || 'npm install failed').trim())
+ }
+}
+
+function renderWithNode(projectSlug, renderTemplate = 'html') {
+ const script = `
+const path = require('path');
+const fs = require('fs');
+const Email = require('email-templates');
+
+async function run() {
+ // For node -e, first extra arg starts at argv[1]
+ const project = process.argv[1];
+ const renderTemplate = process.argv[2];
+ const root = process.argv[3];
+ const email = new Email();
+ const html = await email.render({
+ path: project + '/' + renderTemplate,
+ juiceResources: {
+ preserveImportant: true,
+ applyStyleTags: true,
+ removeStyleTags: true,
+ preserveMediaQueries: true,
+ webResources: {
+ relativeTo: path.resolve(root, 'emails', project)
+ }
+ },
+ }, { pretty: true });
+
+ const outDir = path.resolve(root, 'public');
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
+ const outPath = path.resolve(outDir, 'index.html');
+ const normalized = String(html).replace('#MAILRU_PREHEADER_TAG#', ' ');
+ fs.writeFileSync(outPath, normalized, 'utf-8');
+}
+
+run().catch((error) => {
+ console.error(error && error.stack ? error.stack : String(error));
+ process.exit(1);
+});
+`
+
+ const run = spawnSync(process.execPath, ['-e', script, projectSlug, renderTemplate, EMAIL_GEN_ROOT], {
+ cwd: EMAIL_GEN_ROOT,
+ encoding: 'utf-8',
+ shell: false,
+ timeout: 120000,
+ })
+
+ if (run.error || run.status !== 0) {
+ throw new Error((run.stderr || run.stdout || run.error?.message || 'render failed').trim())
+ }
+}
+
+const server = http.createServer(async (req, res) => {
+ if (req.method === 'GET' && req.url === '/health') {
+ return send(res, 200, { ok: true })
+ }
+
+ if (req.method === 'POST' && req.url === '/render') {
+ let body
+ try { body = await readBody(req) } catch (e) { return send(res, 400, { error: e.message }) }
+ const projectSlug = sanitizeProjectSlug(body.projectSlug)
+ const pug = String(body.pug || '')
+ const preheader = String(body.preheader || '')
+ const gender = String(body.gender || 'female')
+ const genderPaths = body.genderPaths || {}
+
+ if (!projectSlug) return send(res, 400, { error: 'missing_project_slug', details: 'Project slug required' })
+ if (!pug.trim()) return send(res, 400, { error: 'missing_pug', details: 'PUG required' })
+
+ if (!fs.existsSync(EMAIL_GEN_ROOT)) {
+ return send(res, 500, { error: 'email_gen_not_found', details: 'email-gen root missing' })
+ }
+
+ const emailsRoot = path.resolve(EMAIL_GEN_ROOT, 'emails')
+ const projectDir = path.resolve(emailsRoot, projectSlug)
+ if (!projectDir.startsWith(emailsRoot)) {
+ return send(res, 400, { error: 'invalid_project_slug', details: 'Invalid project slug' })
+ }
+ if (!fs.existsSync(projectDir)) {
+ return send(res, 404, { error: 'email_project_not_found', details: `Project ${projectSlug} not found` })
+ }
+
+ try {
+ ensureNpmDeps()
+ const lettersDir = path.resolve(projectDir, 'letters')
+ if (!fs.existsSync(lettersDir)) fs.mkdirSync(lettersDir, { recursive: true })
+ fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8')
+ rewriteHtmlPug(projectDir, preheader, gender, genderPaths)
+ renderWithNode(projectSlug, 'html')
+
+ const htmlPath = path.resolve(EMAIL_GEN_ROOT, 'public', 'index.html')
+ if (!fs.existsSync(htmlPath)) {
+ return send(res, 500, { error: 'preview_not_found', details: 'public/index.html not found' })
+ }
+
+ const html = fs.readFileSync(htmlPath, 'utf-8')
+ return send(res, 200, {
+ html,
+ generatedAt: new Date().toISOString(),
+ })
+ } catch (error) {
+ return send(res, 500, {
+ error: 'render_failed',
+ details: error?.message || 'Unknown render error',
+ })
+ }
+ }
+
+ return send(res, 404, { error: 'not_found' })
+})
+
+server.listen(PORT, () => {
+ console.log(`email-gen-api listening on :${PORT}`)
+ console.log(`email-gen root: ${EMAIL_GEN_ROOT}`)
+})
diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf
new file mode 100644
index 0000000..2de4456
--- /dev/null
+++ b/deploy/nginx/default.conf
@@ -0,0 +1,25 @@
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 80;
+ server_name _;
+
+ client_max_body_size 20m;
+
+ location / {
+ proxy_pass http://builder:5173;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Vite HMR/WebSocket
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+}
diff --git a/deploy/nginx/favicon.jpg b/deploy/nginx/favicon.jpg
new file mode 100644
index 0000000..ab74f63
Binary files /dev/null and b/deploy/nginx/favicon.jpg differ
diff --git a/deploy/nginx/landing.html b/deploy/nginx/landing.html
new file mode 100644
index 0000000..7d9fcb1
--- /dev/null
+++ b/deploy/nginx/landing.html
@@ -0,0 +1,907 @@
+
+
+
+
+
+ Aspekter — Визуальный конструктор email-рассылок
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Профессиональный email-конструктор
+
Письма, которыепродают. Без кода.
+
+ Единый инструмент для маркетолога: план → конструктор → проверка →
+ экспорт. Всё в одном интерфейсе, без переключений между сервисами.
+
+
+
+
+
+
+
+
+
+
BANNER
+
+
+
Копировать HTML
+
↓ Скачать
+
+
+
+
+
+
+
+
+
+
+
Конструктор
+
Собери письмоиз готовых блоков
+
Drag & drop, поиск блоков, collapse, история — всё для быстрой сборки без кода.
+
+
+
+
⚡
+
Блоки и их управление
+
Добавляй блоки через поиск, меняй порядок перетаскиванием или стрелками. Сворачивай ненужные блоки — рабочая область остаётся чистой. Переиспользуй любимые блоки через Quick Blocks на панели.
+
Drag & Drop Поиск блоков Collapse Quick Blocks
+
+
+
📦
+
Пресеты сборок
+
Сохрани текущую структуру письма как пресет — и применяй к следующим кампаниям одним кликом. Поиск, сортировка, удаление. Пресеты хранятся на сервере и доступны всей команде.
+
+
📧
Баннер + 4 товара + кнопка
12.03
+
📧
Sale: баннер + 6 товаров × 2
05.03
+
+
+
+
+
🕐
+
История изменений
+
Каждое сохранение создаёт снимок. Откати письмо к любой точке в истории — без потери текущей версии. Просматривай все снимки с датой и временем.
+
+
14:32
Добавлен блок «4 товара»
↩ Откат
+
14:18
Изменён текст баннера
↩ Откат
+
13:55
Создано письмо
↩ Откат
+
+
+
+
💾
+
Автосохранение и Ctrl+S
+
Изменения сохраняются автоматически через 600мс после остановки. Индикатор-точка в интерфейсе показывает статус: сохраняется / сохранено / ошибка. Принудительное сохранение — Ctrl+S.
+
Автосохранение Ctrl+S Ctrl+Z — Undo Ctrl+G — Рендер
+
+
+
+
+
+
+
+
+
Редактор текста
+
Текст,который выглядит правильно
+
Встроенные инструменты форматирования — типограф, жирный, переносы — прямо в интерфейсе конструктора.
+
+
+
+
+ Типограф — автоматически исправляет кавычки, тире, пробелы по правилам русской типографики (SOAP API)
+ Жирный — оборачивает выделенный текст в span с font-weight: 700
+ Авто-перенос — умно разбивает текст по ширине блока, предотвращает висячие слова
+ Перенос строки и буллет — быстрая вставка <br> и • кнопками
+ Вставка ссылки с настраиваемым шаблоном UTM-меток
+ Shift+Enter — ручной перенос строки прямо в поле редактирования
+
+
+
+
+
+
⚡ Quick Edit — редактирование в превью
+
+
Кликни на любой элемент в превью — появится плавающий попап для быстрого редактирования прямо на месте. Без поиска поля в списке блоков.
+
+
+
+
+
+
+
+
+
+
+
+
Предпросмотр
+
Три режима.Один клик.
+
Превью, HTML-код и Pug-исходник — в правой панели. Рендер кэшируется по хэшу контента — повторный просмотр мгновенный.
+
+
+
+
Режим 1
+
Живое превью
+
Отрендеренное письмо в iframe. Масштаб 40–100% — меняй ползунком, значение сохраняется в профиле. Рендер запускается автоматически или по Ctrl+G.
+
+
+
+
Режим 2
+
HTML-код
+
Готовый HTML для вставки в ESP. Копируй в буфер одной кнопкой — с нормализацией разметки. Или скачивай как файл.
+
+ Копировать HTML
+ Скачать .html
+ Нормализация
+
+
+
+
Режим 3
+
Pug-исходник
+
Собранный Pug-код письма — для версионирования и архива. Копируй или скачивай. Можно импортировать обратно в другой проект.
+
+ Копировать PUG
+ Скачать .pug
+ Импорт блоков
+
+
+
+
+
+
+
+
+
+
+
+
Сегментация
+
Одно письмо —две версии
+
Женская и мужская подборка в одном файле. Переключение — одной кнопкой, рендер — мгновенно.
+
+ Кнопки Ж / М — рендер с соответствующим хедером и футером
+ Кнопка ⇅ — переставляет блоки относительно блока-разделителя
+ Каждый блок получает сегмент: Общий / Женский / Мужской
+ Пути хедера/футера настраиваются в настройках проекта
+ Версии Ж и М кэшируются отдельно — скорость не страдает
+
+
+
♀ Женская версия
+
⇅
+
♂ Мужская версия
+
+
+
+
+
📦 Пул ID из плана
+
+
Вставь список ID товаров — система автоматически распределит их по блокам (3–4 ID на блок). Женские ID — первыми, мужские — следом. Счётчик показывает использованные/всего.
+
+
Пул ID 8 / 20 использовано
+
+
Блок 1 (Жен)
112233, 445566, 778899, 001122
+
Блок 2 (Муж)
334455, 667788, 990011, 223344
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Контроль качества
+
Ни один«нет в наличии» не пройдёт
+
Система парсит XML-фид VipAvenue и автоматически находит проблемные товары прямо в превью письма.
+
+
+
+
🔎
+
Автопроверка наличия
+
Кликни «Проверить» — система сверяет каждый артикул в письме с актуальным фидом. Товары без остатка подсвечиваются красным прямо в превью. Никакой ручной проверки перед отправкой.
+
+
Платье Jacquemus Rouge
НЕТ В НАЛИЧИИ
+
Блузка Self-Portrait
В НАЛИЧИИ
+
Жакет Max Mara
НЕТ В НАЛИЧИИ
+
+
+
+
✨
+
Умный подбор замен
+
Для каждого отсутствующего товара — список замен. Алгоритм учитывает тип изделия, бренд, гендер и ценовой диапазон. Товары дороже ×3 — исключаются автоматически. Hover на фото замены — показывает крупное изображение, название и цену.
+
Тип товара Бренд Гендер Цена ± Hover-превью
+
+
Платье Rotate → замена: A.W.A.K.E Mode
+12%
+
Платье Rotate → замена: LoveShackFancy
-8%
+
+
+
+
🔗
+
Проверка всех ссылок
+
Система извлекает все href и src из письма и проверяет их доступность. Результат — список с цветовыми индикаторами. Сломанные ссылки и 404 заметны сразу.
+
+
https://vipavenue.ru/sale/spring
200 OK
+
https://img.vipavenue.ru/banner.jpg
200 OK
+
https://vipavenue.ru/old-page
404
+
+
+
+
✏️
+
Проверка орфографии
+
Яндекс.Спеллер проверяет русский и английский текст прямо в превью письма. Ошибки подсвечиваются, счётчик показывает их количество. Проверяются только первые 9 500 символов.
+
«весеная» коллекция
→ весенняя
+
Ограниченое предложение
→ ограниченное
+
+
+
+
+
+
+
+
+
+
+
Планирование
+
Редакционныйкалендарь внутри
+
Все предстоящие рассылки — в одной вкладке. Переход из плана в конструктор нужного письма — одним кликом.
+
+ Загрузка рассылок из внешнего источника (Yonote)
+ Фильтры: просрочено / сегодня / завтра / неделя / позже
+ Тема, прехедер и ID писем подтягиваются в конструктор автоматически
+ Кнопка «Отправлено» — обновляет статус в источнике
+ Пуш-уведомления о новых рассылках в браузере
+ Гендерные ID (Ж / М) в одной строке плана
+
+
+
+
+
📅 План рассылок — Март 2026
+
+
+
14.03 Весенняя коллекция ГОТОВО
+
18.03 Sale до –40% В РАБОТЕ
+
22.03 Новые поступления ПЛАН
+
25.03 Тренды сезона ПЛАН
+
28.03 Бренд-фокус: Max Mara ПЛАН
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Медиабиблиотека
+
FTP-галереяпрямо в браузере
+
Загружай изображения для писем без FTP-клиентов. Пакетная загрузка, просмотр, копирование URL — не выходя из конструктора.
+
+ Пакетная загрузка — несколько файлов одним выбором
+ Папки организованы по дате письма — автоматически
+ Клик по миниатюре — копирует URL в буфер
+ Прогресс загрузки: N из M / процент
+ Удаление файлов с подтверждением
+ Поддержка FTP и SFTP
+
+
+
+
+
🖼 FTP Галерея — 03-14
+
+
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
+
+ Загрузить файлы (3/5 загружается...)
+
+
+
+
+
+
+
+
+
+
+
Специальные блоки
+
Сертификаты и расширенные блоки
+
Специализированный редактор сертификатов — отдельный тип контента с перетаскиванием, типографом и форматированием.
+
+
+
+
🏆
+
Редактор сертификатов
+
Отдельный режим для блоков-сертификатов. Добавляй строки, меняй их порядок перетаскиванием. Жирный, перенос, буллеты, Типограф — всё как в основном редакторе. Готовый сертификат сохраняется в письмо.
+
+
⠿
Дорогой клиент, дарим вам
+
+
⠿
действителен до 31 декабря 2026
+
+
+
+
⚙️
+
Опции товарных блоков
+
Для каждого блока с товарами — набор переключаемых опций (скидка, новинка, хит и т.д.). Включай и выключай опцию для конкретного блока или сразу для всех. Визуальный индикатор состояния: вкл / частично / выкл.
+
Скидка Новинка Хит Эксклюзив + Свои опции
+
+
+
+
+
+
+
+
+
Процесс
+
От плана доготового HTML
+
Три шага без переключений между инструментами.
+
+
+
01
+
Открой план
+
Перейди в редакционный календарь. Найди нужную рассылку — дата, тема и прехедер уже заполнены. Нажми «Собрать» — письмо откроется в конструкторе с нужными данными.
+
+
+
02
+
Собери письмо
+
Добавляй блоки, редактируй тексты, загружай баннеры через FTP-галерею. Вставь ID из пула — распределятся сами. Переключи гендерную версию — убедись, что обе выглядят идеально.
+
+
+
03
+
Проверь и отправь
+
Проверь наличие товаров, орфографию и ссылки. Всё чисто — копируй HTML и вставляй в платформу. Обнови статус «Отправлено» прямо из интерфейса.
+
+
+
+
+
+
+
+
+
Платформа
+
Настраиваетсяпод проект
+
Детальные настройки для каждого проекта, ролевой доступ и статистика трудозатрат.
+
+
+
+
Настройки
+
Гибкая конфигурация
+
Логотип, акцентный цвет, отступы. Для каждого блока — шаблон, видимость полей, подписи. Редактор Pug-частей (хедер/футер) прямо из интерфейса. Шаблон ссылок с UTM, авто-нумерация изображений.
+
+
+
Авторизация
+
Безопасность
+
scrypt-хэши паролей. Сессии 7 дней, хранятся на сервере (переживают перезапуск). Тема, zoom, активная страница — сохраняются в профиле. Настройки доступны сразу на любом устройстве.
+
+
+
Команда
+
Управление пользователями
+
Создавай пользователей, назначай роли (admin / user) и проекты. Каждый видит только свои проекты. Смена пароля и настроек профиля — из интерфейса.
+
+
+
+
Аналитика
+
Статистика времени
+
Трекер фиксирует время работы над каждым письмом. Статусы: черновик, в работе, отправлено. Агрегированная статистика по месяцам — чтобы планировать нагрузку команды.
+
+
+
+
+
+
+
+
+
+
Готовы делать письма быстрее?
+
Войдите в систему и начните прямо сейчас.
+
Войти в Aspekter →
+
+
+
+
+
+
+
+
diff --git a/deploy/scripts/update-email-gen.sh b/deploy/scripts/update-email-gen.sh
new file mode 100755
index 0000000..1b1eca2
--- /dev/null
+++ b/deploy/scripts/update-email-gen.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
+EMAIL_GEN_DIR="$ROOT_DIR/email-gen"
+
+if [[ ! -d "$EMAIL_GEN_DIR/.git" ]]; then
+ echo "[error] email-gen git repo not found: $EMAIL_GEN_DIR"
+ exit 1
+fi
+
+BRANCH="${1:-}"
+
+cd "$EMAIL_GEN_DIR"
+
+echo "[info] fetching email-gen..."
+git fetch --all --prune
+
+if [[ -n "$BRANCH" ]]; then
+ echo "[info] checking out branch: $BRANCH"
+ git checkout "$BRANCH"
+fi
+
+CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
+echo "[info] pulling branch: $CURRENT_BRANCH"
+git pull --ff-only origin "$CURRENT_BRANCH"
+
+echo "[info] rebuilding email-gen-api container..."
+cd "$ROOT_DIR"
+docker compose -f docker-compose.prod.yml up -d --build email-gen-api
+
+echo "[ok] email-gen updated and email-gen-api restarted"
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..8873e11
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,7 @@
+services:
+ builder:
+ volumes:
+ - ./z51-pug-builder/data-dev:/app/data
+ - ./z51-pug-builder/src:/app/src
+ - ./z51-pug-builder/vite.config.js:/app/vite.config.js
+ - ./z51-pug-builder/public/login-bg:/app/public/login-bg
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..564b623
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,24 @@
+services:
+ builder:
+ image: vaaspekter-builder
+ container_name: va-builder
+ environment:
+ - EMAIL_GEN_API_URL=http://email-gen-api:8787
+ - NODE_OPTIONS=--max-old-space-size=3072
+ ports:
+ - "127.0.0.1:6001:5173"
+ volumes:
+ - ./data:/app/data
+ - ./email-gen:/email-gen
+ restart: unless-stopped
+ depends_on:
+ - email-gen-api
+
+ email-gen-api:
+ image: vaaspekter-email-gen-api
+ container_name: va-email-gen-api
+ environment:
+ - EMAIL_GEN_ROOT=/workspace/email-gen
+ volumes:
+ - ./email-gen:/workspace/email-gen
+ restart: unless-stopped
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..395c465
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+services:
+ builder:
+ build:
+ context: .
+ dockerfile: z51-pug-builder/Dockerfile
+ environment:
+ - EMAIL_GEN_API_URL=http://email-gen-api:8787
+ ports:
+ - "127.0.0.1:6001:5173"
+ volumes:
+ - ./z51-pug-builder/data:/app/data
+ - ./z51-pug-builder/src:/app/src
+ - ./z51-pug-builder/vite.config.js:/app/vite.config.js
+ - ./email-gen:/email-gen
+ depends_on:
+ - email-gen-api
+
+ email-gen-api:
+ build:
+ context: .
+ dockerfile: deploy/email-gen-api/Dockerfile
+ environment:
+ - EMAIL_GEN_ROOT=/workspace/email-gen
+ volumes:
+ - ./email-gen:/workspace/email-gen
+ - email-gen-node-modules:/workspace/email-gen/node_modules
+
+volumes:
+ email-gen-node-modules:
diff --git a/docs/01-user-guide.md b/docs/01-user-guide.md
new file mode 100644
index 0000000..e24ef2d
--- /dev/null
+++ b/docs/01-user-guide.md
@@ -0,0 +1,238 @@
+# Aspekter VA — Руководство пользователя
+
+## Что это такое
+
+Aspekter VA — визуальный конструктор email-рассылок для интернет-магазина VipAvenue. Позволяет собирать письма из готовых блоков, редактировать тексты, подставлять товары из фида, проверять орфографию и выгружать готовый HTML для отправки через Mindbox.
+
+Адрес: **https://aspekter.ru**
+
+---
+
+## Вход в систему
+
+1. Открыть https://aspekter.ru
+2. Ввести логин и пароль
+3. Нажать «Войти»
+
+При первом запуске создаётся учётная запись `admin` с временным паролем (выводится в консоль контейнера). После входа рекомендуется сменить пароль через панель администратора.
+
+Сессия живёт 7 дней. После этого потребуется повторный вход.
+
+---
+
+## Основной интерфейс
+
+### Страницы (навигация слева)
+
+| Страница | Назначение |
+|---|---|
+| **Конструктор** | Сборка письма из блоков |
+| **План** | Календарь рассылок из Yonote |
+| **Статистика** | Время работы над письмами |
+| **Настройки** | Параметры проекта |
+
+### Конструктор — левая панель
+
+- **Список блоков** — перетаскивание, добавление, удаление, изменение порядка
+- **Поля блока** — при выборе блока показываются редактируемые поля (тексты, ссылки, картинки, ID товаров)
+- **Пресеты** — сохранённые наборы блоков для быстрого старта
+
+### Конструктор — правая панель (превью)
+
+- **Превью письма** — iframe с отрендеренным HTML
+- **Масштаб** — ползунок 40–100%
+- **Кнопки Жен / Муж** — переключение гендерной версии превью
+- **Кнопка ⇅** — переставляет блоки для мужской/женской версии (flip)
+- **Копировать HTML** — копирует итоговый HTML в буфер обмена
+
+---
+
+## Работа с блоками
+
+### Добавление блока
+1. Нажать «+ Добавить блок» в левой панели
+2. Выбрать блок из списка (появится dropdown с иконками)
+3. Блок добавляется в конец списка
+
+### Редактирование полей
+Каждый блок раскрывается по клику. Внутри — поля:
+- **Текст** — многострочное поле, поддерживает пробелы и переносы
+- **Ссылка** — URL (кнопки, картинки, ссылки меню)
+- **ID товаров** — через запятую, подтягиваются из фида
+- **Картинка** — URL изображения
+
+### Перетаскивание
+Блоки можно перетаскивать за иконку ⠿ слева от названия.
+
+### Удаление
+Кнопка 🗑 справа от блока.
+
+### Секции внутри блока
+Некоторые блоки (например «Текст») состоят из секций. Секции можно:
+- Менять местами перетаскиванием
+- Удалять (кроме первой)
+- Добавлять новые
+
+---
+
+## Гендерная сегментация
+
+Письма VipAvenue отправляются в двух версиях: женской и мужской.
+
+### Как это работает
+- Каждое письмо собирается с набором блоков
+- **Кнопка «Жен» / «Муж»** — переключает превью на соответствующую версию (меняет header/footer и порядок блоков)
+- **Кнопка ⇅ (flip)** — физически переставляет блоки: блоки до разделителя и после меняются местами
+- **Разделитель (⊕)** — блок-разделитель, служит осью для flip. Устанавливается кнопкой ⊕ только на блоке типа «dividerVA»
+
+### Хедеры и футеры
+Настраиваются в разделе «Настройки» → «Гендерные пути»:
+- Хедер женский / мужской
+- Футер женский / мужской
+
+Выбираются из списка файлов в `email-gen/emails/vipavenue/parts/`.
+
+---
+
+## Товары из фида
+
+### Настройка фида
+В настройках проекта указывается URL XML-фида Mindbox. Фид кэшируется на 3 часа.
+
+### Подстановка товаров
+1. В блоке с товарами ввести ID через запятую
+2. Система найдёт товары в фиде и покажет превью (название, цена, картинка)
+3. Если товар не найден или недоступен — появится предупреждение
+
+### Замена товара
+Если товар недоступен, система предложит замену. Алгоритм подбора:
+- Тот же тип товара (+20 баллов)
+- Та же категория (+15)
+- Тот же бренд (+25)
+- Тот же гендер (+10)
+- Близкая цена (+0..10, исключение если >3x разница)
+- Тот же цвет (+5)
+
+### Пул ID
+В левой панели можно вести «Пул ID» — список товаров для быстрой вставки.
+
+---
+
+## Пресеты
+
+Пресет — сохранённый набор блоков с их содержимым.
+
+- **Сохранить** — кнопка «Сохранить как пресет», вводится название
+- **Загрузить** — выбрать из списка, блоки заменятся
+- **Удалить** — кнопка удаления в списке пресетов
+
+---
+
+## Письма (карточки рассылок)
+
+### Создание
+Каждая рассылка — карточка с полями:
+- Тема письма
+- Дата
+- Тег (категория)
+- Набор блоков
+
+### Список писем
+В левой панели — список карточек. Можно:
+- Создать новое письмо
+- Открыть существующее
+- Удалить
+- Просмотреть историю изменений
+
+### История
+Каждое сохранение создаёт снимок. Можно откатиться к предыдущей версии.
+
+---
+
+## Заметки
+
+В боковой панели есть раздел «Заметки» — текстовые заметки привязанные к проекту.
+- Создание, редактирование, удаление
+- Данные хранятся на сервере
+
+---
+
+## План рассылок
+
+Страница «План» подтягивает данные из Yonote (API). Показывает:
+- Календарь с отмеченными датами рассылок
+- Список с темами, статусами, датами
+- Уведомления о новых/удалённых рассылках (polling каждые 3 минуты)
+
+---
+
+## Проверка ссылок
+
+Кнопка «Проверить ссылки» в превью:
+- Находит все URL в сгенерированном HTML
+- Проверяет каждый (HEAD-запрос)
+- Показывает статус: ✅ работает, ❌ битая, ⚠️ редирект
+
+---
+
+## Проверка орфографии
+
+Использует Яндекс.Спеллер. Подсвечивает ошибки в превью красным волнистым подчёркиванием с подсказкой при наведении.
+
+---
+
+## Типографика
+
+Кнопка «Типограф» отправляет тексты блоков через сервис Артемия Лебедева (typograf.artlebedev.ru). Применяет:
+- Неразрывные пробелы после предлогов
+- Правильные кавычки «ёлочки»
+- Тире вместо дефиса
+- И другие правила русской типографики
+
+---
+
+## FTP / Галерея изображений
+
+### Настройка
+В настройках проекта → FTP:
+- Хост, порт, логин, пароль
+- Протокол (FTP / SFTP)
+- Удалённый путь
+- Публичный URL
+
+### Использование
+- Кнопка «Галерея» открывает модальное окно
+- Загрузка: выбрать файлы (множественный выбор), загрузка автоматическая
+- Копирование URL: клик по миниатюре
+- Удаление: кнопка на миниатюре
+
+---
+
+## Копирование HTML
+
+1. Нажать «Копировать HTML»
+2. HTML копируется в буфер обмена
+3. Вставить в Mindbox
+
+**Важно:** в сгенерированном HTML предлоги и короткие слова (≤3 символов) склеены со следующим словом через `` — это предотвращает висячие предлоги в почтовых клиентах.
+
+---
+
+## Тема оформления
+
+Переключатель светлая/тёмная тема — в верхней панели. Настройка сохраняется в профиле пользователя на сервере.
+
+---
+
+## Администрирование
+
+Доступно только пользователям с ролью `admin`.
+
+### Управление пользователями
+- Создание новых пользователей (логин, пароль ≥8 символов, имя, роль)
+- Редактирование (смена пароля, имени, роли)
+- Удаление (нельзя удалить самого себя)
+
+### Роли
+- **admin** — полный доступ + управление пользователями
+- **user** — работа с конструктором
diff --git a/docs/02-developer-guide.md b/docs/02-developer-guide.md
new file mode 100644
index 0000000..02cdfee
--- /dev/null
+++ b/docs/02-developer-guide.md
@@ -0,0 +1,574 @@
+# Aspekter VA — Руководство разработчика
+
+## 1. Обзор архитектуры
+
+Aspekter VA — монолитное web-приложение для сборки email-рассылок VipAvenue.
+
+```
+┌─────────────────────────────────────────────────┐
+│ Браузер │
+│ App.svelte (UI) ←→ api.js (HTTP client) │
+└────────────────────┬────────────────────────────┘
+ │ HTTP
+┌────────────────────▼────────────────────────────┐
+│ builder (порт 6001) │
+│ Vite dev server + API middleware │
+│ vite.config.js — весь backend (~1500 строк) │
+└────────────────────┬────────────────────────────┘
+ │ HTTP (внутренний)
+┌────────────────────▼────────────────────────────┐
+│ email-gen-api (порт 8787) │
+│ server.js — Pug → HTML рендер │
+│ email-gen/ — шаблоны, стили, миксины │
+└─────────────────────────────────────────────────┘
+```
+
+### Стек
+- **Frontend:** Svelte 5 + Vite 7
+- **Backend:** Vite middleware plugin (Node.js, встроен в dev-server)
+- **Email рендер:** Pug шаблоны + email-templates (npm)
+- **Хранение данных:** файловая система (JSON)
+- **Инфраструктура:** Docker Compose, nginx reverse proxy
+
+### Почему так устроено
+Backend встроен как Vite middleware plugin, а не как отдельный сервер. Это значит:
+- Один процесс, один порт
+- HMR для фронтенда работает из коробки
+- Нет CORS-проблем
+- Но: vite.config.js не перезагружается через HMR — нужен `docker restart builder`
+
+---
+
+## 2. Структура файлов
+
+```
+VA.ASPEKTER/
+├── docker-compose.yml # Оркестрация контейнеров
+├── deploy/
+│ ├── nginx/ # Nginx конфиг + лендинг
+│ │ ├── nginx.conf
+│ │ └── landing.html
+│ └── email-gen-api/
+│ ├── Dockerfile
+│ └── server.js # Standalone Pug render service
+├── email-gen/ # Git-репозиторий коллег (шаблоны)
+│ └── emails/vipavenue/
+│ ├── layout/layout.pug # Базовый layout (width=600)
+│ ├── blocks/block.pug # Все блоки конструктора
+│ ├── includes/mixins.pug # Pug миксины (товары, кнопки, preheader)
+│ ├── css/style.css # Стили (инлайнятся при рендере)
+│ └── parts/ # Хедеры, футеры (по гендеру)
+│ ├── header/
+│ └── footer/
+└── z51-pug-builder/ # Основное приложение
+ ├── Dockerfile
+ ├── package.json
+ ├── vite.config.js # ВЕСЬ BACKEND API (~1500 строк)
+ ├── index.html # Входная точка Vite
+ ├── public/ # Статика (favicon)
+ ├── src/
+ │ ├── App.svelte # ВЕСЬ FRONTEND UI (~6400 строк)
+ │ ├── app.css # Все стили
+ │ ├── main.js # Точка входа Svelte
+ │ └── lib/
+ │ ├── api.js # API-клиент (все эндпоинты)
+ │ ├── parsing.js # Парсинг Pug-блоков, миксинов, аргументов
+ │ ├── spellcheck.js # Проверка орфографии (Yandex Speller)
+ │ └── utils.js # Утилиты (normalizeNewlines, escapeRegExp, etc)
+ └── data/ # Данные (том Docker)
+ ├── config.json # Глобальный конфиг (Yonote токен, URL)
+ ├── feed-cache.json # Кэш товарного фида
+ ├── render-cache.json # Кэш рендера (Pug→HTML)
+ ├── _system/
+ │ ├── users.json # Пользователи (логин, хэш пароля, роль)
+ │ └── sessions.json # Активные сессии
+ └── vipavenue/ # Данные проекта
+ ├── settings.json # Настройки (блоки, FTP, гендер, фид)
+ ├── block.pug # Текущий пуг-файл конструктора
+ ├── presets.json # Пресеты
+ ├── notes.json # Заметки (индекс)
+ ├── notes/ # Файлы заметок
+ ├── stats.json # Статистика времени
+ ├── drafts/ # Черновики по пользователям
+ └── letters/ # Карточки писем по пользователям
+ └── {userId}/
+ ├── letters.json # Индекс писем
+ ├── {id}.json # Данные письма
+ └── {id}.history.json # История снимков
+```
+
+---
+
+## 3. Docker — контейнеры и тома
+
+### Контейнеры
+
+| Контейнер | Образ | Порт | Назначение |
+|---|---|---|---|
+| `builder` | z51-pug-builder/Dockerfile | 6001→5173 | Vite dev server + API |
+| `email-gen-api` | deploy/email-gen-api/Dockerfile | (внутренний) 8787 | Pug→HTML рендер |
+| nginx | (отдельно) | 80/443 | Reverse proxy |
+
+### Тома builder
+
+| Хост | Контейнер | Режим | HMR? |
+|---|---|---|---|
+| `./z51-pug-builder/src` | `/app/src` | rw | ✅ Мгновенно |
+| `./z51-pug-builder/vite.config.js` | `/app/vite.config.js` | rw | ❌ Нужен restart |
+| `./z51-pug-builder/data` | `/app/data` | rw | — |
+| `./email-gen` | `/email-gen` | rw | ❌ Нужен build |
+
+### Тома email-gen-api
+
+| Хост | Контейнер |
+|---|---|
+| `./email-gen` | `/workspace/email-gen` (rw) |
+| Named volume | `/workspace/email-gen/node_modules` |
+
+### Правила пересборки (КРИТИЧЕСКИ ВАЖНО)
+
+**`docker compose build && docker compose up -d` нужен при:**
+- Изменения в `email-gen/` (pug шаблоны, миксины, стили)
+- Изменения в `deploy/email-gen-api/server.js`
+- Изменения в `z51-pug-builder/Dockerfile`
+- Изменения в `z51-pug-builder/package.json` (новые зависимости)
+
+**`docker compose restart builder` нужен при:**
+- Изменения в `z51-pug-builder/vite.config.js`
+
+**Ничего не нужно (HMR подхватит):**
+- Изменения в `z51-pug-builder/src/` (App.svelte, app.css, lib/*.js)
+
+> **НЕ использовать** `docker cp` или `docker exec` для правки исходников на проде. Только редактировать на хосте, тома примонтированы.
+
+---
+
+## 4. Backend API — все эндпоинты
+
+Весь API реализован в `vite.config.js` как Vite middleware plugin.
+
+### Авторизация (без аутентификации)
+
+| Метод | URL | Описание |
+|---|---|---|
+| POST | `/api/auth/login` | Вход: `{login, password}` → cookie `va_token` |
+| POST | `/api/auth/logout` | Выход: удаляет cookie |
+| GET | `/api/auth/me` | Текущий пользователь (или 401) |
+| PUT | `/api/auth/preferences` | Сохранить тему/activePage/zoom |
+
+### Администрирование (role: admin)
+
+| Метод | URL | Описание |
+|---|---|---|
+| GET | `/api/admin/users` | Список пользователей |
+| POST | `/api/admin/users` | Создать: `{login, password, name, role}` |
+| PUT | `/api/admin/users/:id` | Обновить пользователя |
+| DELETE | `/api/admin/users/:id` | Удалить (нельзя удалить себя) |
+
+### Проект (все требуют аутентификации)
+
+| Метод | URL | Описание |
+|---|---|---|
+| GET | `/api/projects` | Список проектов (всегда `["vipavenue"]`) |
+| GET | `/api/project/:name` | Данные проекта (блок, настройки, черновик, пресеты, письма, заметки) |
+| PUT | `/api/project/:name/block` | Сохранить block.pug |
+| PUT | `/api/project/:name/block-custom` | Сохранить кастомный блок |
+| PUT | `/api/project/:name/settings` | Сохранить настройки |
+| PUT | `/api/project/:name/draft` | Сохранить черновик |
+| PUT | `/api/project/:name/presets` | Сохранить пресеты |
+
+### Письма
+
+| Метод | URL | Описание |
+|---|---|---|
+| GET | `/api/project/:name/letter/:id` | Получить письмо |
+| PUT | `/api/project/:name/letter` | Сохранить: `{id, ...data}` |
+| DELETE | `/api/project/:name/letter/:id` | Удалить |
+| GET | `/api/project/:name/letter/:id/history` | История снимков |
+| PUT | `/api/project/:name/letter/:id/history` | Добавить снимок |
+
+### Заметки
+
+| Метод | URL | Описание |
+|---|---|---|
+| GET | `/api/project/:name/notes` | Индекс заметок |
+| PUT | `/api/project/:name/notes` | Сохранить индекс |
+| GET | `/api/project/:name/note/:id` | Получить заметку |
+| PUT | `/api/project/:name/note` | Сохранить: `{id, ...data}` |
+| DELETE | `/api/project/:name/note/:id` | Удалить |
+
+### Рендер email
+
+| Метод | URL | Описание |
+|---|---|---|
+| POST | `/api/project/:name/render-email` | Рендер Pug→HTML. Body: `{projectSlug, pug, preheader, gender}` |
+
+**Цепочка рендера:**
+1. Frontend отправляет Pug-код + preheader + gender
+2. Backend формирует hash, проверяет кэш
+3. Если кэш-промах → отправляет в email-gen-api
+4. email-gen-api: записывает `letters/let.pug`, генерирует `html.pug`, запускает `email-templates` → `public/index.html`
+5. Backend читает HTML, обрабатывает Mindbox-теги (подставляет товары), применяет nowrap
+6. Возвращает `{html, previewHtml, unavailableProducts}`
+
+### Товарный фид
+
+| Метод | URL | Описание |
+|---|---|---|
+| POST | `/api/project/:name/feed-refresh` | Очистить кэш фида, перезагрузить |
+| POST | `/api/project/:name/feed-lookup` | Найти товары по ID: `{ids: [...]}` |
+| POST | `/api/project/:name/feed-suggest` | Подобрать замену: `{productId, excludeIds, search}` |
+
+Фид кэшируется на 3 часа. XML парсится regex-ом (не DOM), поддерживается windows-1251.
+
+### FTP/SFTP
+
+| Метод | URL | Описание |
+|---|---|---|
+| POST | `/api/project/:name/ftp/test` | Проверить подключение |
+| POST | `/api/project/:name/ftp/upload` | Загрузить файл: `{imageData, fileName, folder}` |
+| POST | `/api/project/:name/ftp/list` | Список файлов: `{folder}` |
+| POST | `/api/project/:name/ftp/delete` | Удалить файл: `{folder, fileName}` |
+
+### Прочее
+
+| Метод | URL | Описание |
+|---|---|---|
+| GET | `/api/config` | Глобальный конфиг (Yonote, upload URL) |
+| PUT | `/api/config` | Сохранить конфиг |
+| GET | `/api/yonote/status` | Статус подключения Yonote |
+| GET | `/api/yonote/databases` | Список баз Yonote |
+| GET | `/api/yonote/database/:id/properties` | Свойства базы |
+| GET | `/api/yonote/database/:id/rows` | Строки базы |
+| POST | `/api/yonote/row/update` | Обновить строку: `{rowId, values}` |
+| POST | `/api/upload-image` | Загрузить изображение: `{imageData, fileName}` |
+| POST | `/api/check-links` | Проверить ссылки: `{urls: [...]}` |
+| GET | `/api/parts-files` | Список pug-файлов из parts/ |
+| GET | `/api/parts-file-read?path=...` | Прочитать parts-файл |
+| POST | `/api/parts-file-write` | Записать parts-файл |
+| GET | `/api/project/:name/stats` | Статистика проекта |
+| POST | `/api/project/:name/stats` | Сохранить запись статистики |
+| GET | `/api/stats` | Вся статистика (все проекты) |
+
+---
+
+## 5. Frontend — архитектура
+
+### Общая структура
+
+Весь UI — один файл `App.svelte` (~6400 строк). Это монолит. Причина — проект начинался как прототип и вырос.
+
+### Ключевые модули (lib/)
+
+**api.js** — HTTP-клиент. Все функции `api*()` вызывают `apiRequest()` который делает `fetch()` с `Content-Type: application/json` и обрабатывает ошибки.
+
+**parsing.js** — парсинг Pug-кода:
+- `parseBlocks(text)` — разбивает Pug на блоки по `//` комментариям
+- `buildBaseSchema(content, blockName, mixinRules)` — строит схему полей блока (какие поля редактируемые)
+- `parseSections(content)` — разбивает блок на секции для drag-n-drop
+- `findMixinArgRange(line, argIndex)` — находит позицию аргумента миксина в строке
+- `extractMixinArgValue()` / `replaceMixinArgQuoted()` — чтение/запись аргументов
+
+**spellcheck.js** — `checkSpelling(text)` отправляет в Yandex Speller, `injectSpellMarks(html, errors)` подсвечивает ошибки в превью.
+
+**utils.js** — `normalizeNewlines()`, `escapeRegExp()`, `unquoteValue()`, `downloadFile()`.
+
+### State management
+
+Svelte 5 reactive `let` переменные. Ключевые группы:
+
+| Группа | Переменные | Описание |
+|---|---|---|
+| UI | `activePage`, `theme`, `sidebarTab`, `settingsBlockIndex` | Состояние интерфейса |
+| Данные | `assembledBlocks`, `presets`, `planRows`, `allStats` | Основные данные |
+| Рендер | `renderedHtml`, `previewHtml`, `autoRenderTimer` | Результат рендера |
+| Гендер | `currentGenderVersion`, `segmentFlipped` | Гендерная сегментация |
+| Фид | `feedProducts`, `unavailableProducts` | Товары |
+| Письма | `currentLetterId`, `letterName` | Текущее письмо |
+| Настройки | `settings` | settings.json проекта |
+
+### Ключевые функции
+
+| Функция | Назначение |
+|---|---|
+| `loadProject()` | Загрузка проекта при старте |
+| `rebuildOutput()` | Пересборка Pug из блоков → рендер |
+| `renderEmail()` | Отправка Pug на сервер для рендера |
+| `assembleGenderVersion(target)` | Сборка гендерной версии |
+| `flipSegmentOrder()` | Переключение порядка блоков (⇅) |
+| `addBlock(name)` / `removeBlock(id)` | Управление блоками |
+| `updateBlockContent(id, content)` | Обновление содержимого блока |
+| `saveDraft()` | Автосохранение черновика |
+| `copyHtml()` | Копирование HTML в буфер |
+| `handlePreviewElementClick(data)` | Клик по элементу в превью → фокус на поле |
+
+### Реактивные вычисления ($:)
+
+```javascript
+$: settingsBlockName = allBlocks[settingsBlockIndex]?.name
+$: settingsTemplate = getEffectiveTemplate(settingsBlockName)
+$: baseSchema = buildBaseSchema(settingsTemplate, settingsBlockName, ...)
+$: allBlocks = [...blockTemplates, ...customBlocksList]
+$: productOptionsList = (settings.productOptions || []).map(...)
+```
+
+---
+
+## 6. Email rendering pipeline
+
+### Полная цепочка
+
+```
+1. App.svelte: assembleGenderVersion() + rebuildOutput()
+ ↓ Собирает Pug из блоков
+2. App.svelte: apiRenderEmail(name, {projectSlug, pug, preheader, gender})
+ ↓ HTTP POST
+3. vite.config.js: /api/project/:name/render-email
+ ↓ pugHash() → проверка кэша
+ ↓ Если промах:
+4. vite.config.js → HTTP POST email-gen-api:8787/render
+ ↓ {projectSlug, pug, preheader, gender, genderPaths}
+5. email-gen-api/server.js:
+ a) Записывает letters/let.pug (содержимое из конструктора)
+ b) rewriteHtmlPug() — генерирует html.pug с правильным header/footer
+ c) renderWithNode() — вызывает email-templates (Pug → HTML)
+ d) Читает public/index.html → возвращает HTML
+6. vite.config.js:
+ a) Кэширует HTML
+ b) processMindboxTags() — подставляет товары из фида
+ c) applyNowrap() — склеивает предлоги с nowrap-спанами
+ d) Возвращает {html, previewHtml, unavailableProducts}
+7. App.svelte: показывает превью в iframe
+```
+
+### Кэш рендера
+- Ключ: `md5(slug + pug + gender + genderPaths)`
+- Максимум 30 записей (LRU)
+- Персистится в `data/render-cache.json`
+- Инвалидируется при смене Pug-кода, гендера или путей header/footer
+
+### Mindbox-обработка
+- Замена `@{for...}@{end for}` блоков
+- Извлечение ID товаров из `GetByValue('12345')`
+- Подстановка данных из фида (цена, название, картинка, URL)
+- Отслеживание недоступных товаров
+
+### Nowrap-обработка
+- Только для текстовых блоков (span с классом `h3`)
+- Оборачивает `предлог + слово` в ``
+- Использует placeholder-подход чтобы избежать двойной обработки
+
+---
+
+## 7. Гендерная сегментация — техническая реализация
+
+### Данные
+```json
+// settings.json → genderPaths
+{
+ "headerFemale": "./parts/header/header-woman",
+ "headerMale": "./parts/header/header-man",
+ "footerFemale": "./parts/footer/footer-woman",
+ "footerMale": "./parts/footer/footer-man"
+}
+```
+
+### Цепочка передачи gender
+
+1. **App.svelte** → `apiRenderEmail(..., { gender: 'female'|'male' })`
+2. **vite.config.js** → читает `genderPaths` из settings.json, форвардит в email-gen-api
+3. **email-gen-api/server.js** → `rewriteHtmlPug(projectDir, preheader, gender, genderPaths)` — записывает `html.pug` с нужным include header/footer
+4. Pug рендерит HTML с правильным header/footer
+
+### Кэш
+`pugHash(slug, pug, gender, genderPaths)` — gender и genderPaths включены в ключ, female/male кэшируются отдельно.
+
+### Flip (⇅)
+- `flipSegmentOrder()` в App.svelte
+- Находит блок-разделитель с `swapCenter = true`
+- Блоки до разделителя и после меняются местами
+- `segmentFlipped` (bool) отслеживает состояние toggle
+
+---
+
+## 8. Авторизация и безопасность
+
+### Аутентификация
+- **Хэширование:** scrypt (salt 16 bytes, key length 64)
+- **Сессии:** случайный токен 32 bytes hex, хранится в Map + файл sessions.json
+- **Cookie:** `va_token`, HttpOnly, SameSite=Strict, Secure, Max-Age=7d
+- **Brute-force:** 5 попыток на IP за 15 минут
+
+### Middleware стек (порядок важен!)
+1. Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
+2. Auth endpoints (/api/auth/*) — без проверки сессии
+3. CSRF проверка (origin/referer vs host)
+4. Auth middleware — проверка cookie → req.user
+5. Admin middleware (/api/admin/*) — проверка role=admin
+6. Uploads middleware (/uploads/*) — статика с проверкой path traversal
+7. API middleware — основные эндпоинты
+
+### Защита от атак
+- **Path traversal:** `sanitizeFileId()` для всех файловых ID, `getProjectDir()` проверяет startsWith
+- **SSRF:** фильтрация приватных IP (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16)
+- **CSRF:** проверка origin/referer на state-changing запросах
+- **XSS:** не применимо (внутренний инструмент, HTML генерируется из шаблонов)
+
+---
+
+## 9. Настройки проекта (settings.json)
+
+```json
+{
+ "globalSpacing": 40,
+ "blocks": {
+ "Текст": { "spacing": 20, "template": "..." },
+ "Кнопка": { "spacing": 10 }
+ },
+ "genderPaths": {
+ "headerFemale": "./parts/header/header-woman",
+ "headerMale": "./parts/header/header-man",
+ "footerFemale": "./parts/footer/footer-woman",
+ "footerMale": "./parts/footer/footer-man"
+ },
+ "feedUrl": "https://...",
+ "ftpConfig": {
+ "host": "...",
+ "port": 21,
+ "user": "...",
+ "password": "***",
+ "protocol": "ftp",
+ "remotePath": "/images",
+ "publicUrl": "https://..."
+ },
+ "mixinRules": [
+ { "mixin": "buttonRounded", "argIndex": 0, "type": "mixin-text", "label": "Текст кнопки" }
+ ],
+ "productOptions": [
+ { "code": "showPrice:true", "label": "Показывать цену", "defaultEnabled": true }
+ ],
+ "customBlocks": [
+ { "id": "...", "name": "Мой блок", "content": "..." }
+ ]
+}
+```
+
+---
+
+## 10. email-gen — шаблоны (репозиторий коллег)
+
+### Структура
+```
+email-gen/emails/vipavenue/
+├── layout/layout.pug # Основной layout, table width=600
+├── blocks/block.pug # Все блоки (//Текст, //Кнопка, //Товары, etc)
+├── includes/mixins.pug # Pug-миксины (+products, +buttonRounded, +preheader, etc)
+├── css/style.css # CSS (инлайнится при рендере)
+└── parts/
+ ├── header/ # Хедеры (header-woman.pug, header-man.pug, header-dark.pug)
+ └── footer/ # Футеры (аналогично)
+```
+
+### Как добавить новый блок
+1. Добавить секцию в `blocks/block.pug`:
+ ```pug
+ //Мой новый блок
+ tr
+ td.paddingWrapper
+ +defaultTable("100%")
+ tr
+ td(align="center")
+ span.font.h3.blackText Текст блока
+ ```
+2. Пересобрать контейнеры: `docker compose build && docker compose up -d`
+3. Блок появится в конструкторе
+
+### Preheader
+- Миксин `+preheader(text)` в mixins.pug
+- Формат: `{текст} ⠀×130`
+- `#MAILRU_PREHEADER_TAG#` в pug заменяется на `` в server.js
+
+---
+
+## 11. Деплой
+
+### Сервер
+- IP: определяется из конфига
+- Проект: `/opt/va/` (или аналогичный путь)
+- Nginx: reverse proxy → localhost:6001
+
+### Обновление кода
+
+**Frontend (App.svelte, app.css, lib/*.js):**
+```bash
+# Файлы примонтированы через том — просто редактируй на хосте
+# HMR подхватит автоматически
+```
+
+**Backend (vite.config.js):**
+```bash
+# Файл примонтирован, но Vite не перечитывает конфиг
+docker compose restart builder
+```
+
+**Шаблоны email (email-gen/):**
+```bash
+docker compose build && docker compose up -d
+```
+
+**email-gen-api/server.js:**
+```bash
+docker compose build email-gen-api && docker compose up -d email-gen-api
+```
+
+### Первый запуск
+```bash
+git clone
+cd VA.ASPEKTER
+docker compose build
+docker compose up -d
+# Смотреть логи для временного пароля admin:
+docker compose logs builder | grep "Временный пароль"
+```
+
+---
+
+## 12. Известные особенности и ограничения
+
+### Архитектурные
+- **Монолит:** App.svelte ~6400 строк, vite.config.js ~1500 строк. Декомпозиция не проводилась.
+- **Файловое хранилище:** нет БД, всё в JSON. Race condition при параллельных записях теоретически возможен (writeFileSync без блокировки).
+- **Single-project:** код спроектирован под vipavenue. Мультипроектность убрана, но следы остались (PROJECT_NAME constant).
+
+### Рендер
+- **Кэш:** 30 записей LRU. При изменении CSS в email-gen кэш не инвалидируется (нужно очистить вручную или пересобрать).
+- **Mindbox-теги:** парсятся regex-ом, не DOM. Если формат тегов изменится — сломается.
+- **XML-фид:** парсится regex-ом. CDATA, вложенные теги с одинаковым именем — потенциально проблемны.
+
+### Фронтенд
+- **Svelte 5:** используется Svelte 5, но не все runes-паттерны применены. `ensureSchema()` мутирует block.schema напрямую.
+- **MutationObserver:** на document.body с subtree=true. Debounce 200ms добавлен, но это всё равно потенциальная нагрузка.
+
+### TODO (отмечено в коде)
+- Google Sheets → Yonote API миграция (поиск по `TODO: YONOTE`)
+- Пользовательские настройки (тема, zoom) частично перенесены на сервер, localStorage ещё используется как fallback
+- Pull email-gen из git — функция запланирована, но не реализована
+
+---
+
+## 13. Conventions
+
+### Именование
+- localStorage ключи: `va-*` (va-active-page, va-theme, va-plan-cache, va-previewZoom)
+- Cookie: `va_token`
+- CSS классы: `va-spell` (spellcheck), `vaclick` (preview detection)
+- Docker: `vaaspekter-builder-1`, `vaaspekter-email-gen-api-1`
+
+### Форматирование кода
+- 2 пробела отступ
+- Одинарные кавычки в JS
+- Без точек с запятой (semicolons) — inconsistent, в некоторых местах есть
+
+### Git
+- `email-gen/` — это отдельный git-репозиторий коллег, вложен как поддиректория (НЕ submodule)
+- Основной репозиторий: `VA.ASPEKTER/`
diff --git a/docs/applyNowrap.md b/docs/applyNowrap.md
new file mode 100644
index 0000000..a2a5a77
--- /dev/null
+++ b/docs/applyNowrap.md
@@ -0,0 +1,168 @@
+# applyNowrap — защита от висячих предлогов в email
+
+## Назначение
+
+Функция `applyNowrap(html)` предотвращает «висячие» предлоги и короткие слова (≤3 букв) в email-рассылках. Оборачивает короткое слово + следующее слово в ``, чтобы они не разрывались на разные строки.
+
+**Почему нужна:** Mail.ru и некоторые другие почтовые клиенты игнорируют ` ` при переносе строк. Единственный надёжный способ — `white-space:nowrap` на ``.
+
+## Полный код
+
+```javascript
+function applyNowrap(html) {
+ function wrapShort(text) {
+ return text.replace(
+ /(?]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi,
+ (_match, open, content, close) => {
+ // Шаг 2: Обработать текст МЕЖДУ тегами: >текст<
+ const processed = content.replace(
+ />([^<]+) '>' + wrapShort(t) + '<'
+ )
+ // Шаг 3: Обработать текст В НАЧАЛЕ (до первого тега)
+ const firstText = processed.replace(
+ /^([^<]+)/,
+ (m) => wrapShort(m)
+ )
+ return open + firstText + close
+ }
+ )
+ // Шаг 4: Заменить placeholder'ы на реальные span-теги
+ return result
+ .replace(/\u200Bspan\u200Bnwr\u200B/g, '')
+ .replace(/\u200B\/span\u200Bnwr\u200B/g, ' ')
+}
+```
+
+## Алгоритм пошагово
+
+### Шаг 1 — Выбор целевых блоков
+
+```
+/(]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi
+```
+
+Ищет `` с классом `h3` (текстовые блоки писем), закрытый ` `.
+
+Три группы захвата:
+- `open` — открывающий тег ``
+- `content` — всё содержимое между открывающим и закрывающим тегами
+- `close` — закрывающий ` `
+
+> **Адаптация для другого проекта:** заменить `h3` на нужный CSS-класс текстовых блоков. Заменить ` ` на ваш закрывающий паттерн.
+
+### Шаг 2 — Обработка текста между вложенными тегами
+
+```
+/>([^<]+)` и `<` (текст между HTML-тегами внутри блока). Например в `Что это дает ?` поймает `Что это дает`.
+
+### Шаг 3 — Обработка текста в начале блока
+
+```
+/^([^<]+)/
+```
+
+Ловит текст от начала content до первого HTML-тега. Нужен отдельно, потому что regex из шага 2 ищет `>текст<`, а в начале блока нет `>` перед текстом.
+
+### Шаг 4 — Placeholder → реальные теги
+
+```javascript
+.replace(/\u200Bspan\u200Bnwr\u200B/g, '')
+.replace(/\u200B\/span\u200Bnwr\u200B/g, ' ')
+```
+
+Заменяет placeholder-маркеры на настоящие HTML-теги.
+
+**Зачем placeholder'ы?** Если бы мы сразу вставляли ``, то regex из шага 2 (`>([^<]+)<`) мог бы снова поймать текст внутри уже вставленного span'а и обработать его повторно. Маркеры `\u200B` (zero-width space) невидимы и не матчатся как `<` или `>`, поэтому двойной обработки не происходит.
+
+## Ключевой regex — wrapShort
+
+```
+/(?` |
+| `$1` | Короткое слово (предлог) |
+| `\u00A0` | Non-breaking space между словами (двойная защита) |
+| `$2` | Следующее слово |
+| `\u200B/span\u200Bnwr\u200B` | Placeholder для ` ` |
+
+## Результат на примере
+
+**Вход:**
+```html
+С первыми весенними днями дизайнеры и стилисты
+```
+
+**Выход:**
+```html
+
+ С первыми весенними днями
+ дизайнеры и стилисты
+
+```
+
+## Исправленные баги
+
+### Баг 1: ` ` не матчился (2026-03-20)
+
+**Симптом:** Nowrap-спаны не появляются в HTML, хотя функция вызывается.
+
+**Причина:** Типограф (Артемий Лебедев / любой другой) вставляет ` ` между предлогами и словами. Оригинальный regex искал только `\s+` и не находил ` `.
+
+**Фикс:** Заменить `\s+` на `(?:\s| )+`.
+
+### Баг 2: `\b` не работает с кириллицей (2026-03-21)
+
+**Симптом:** Regex вообще ничего не матчит для кириллических предлогов (С, и, в, на, от, из, но, до, по, за, ко).
+
+**Причина:** В JavaScript `\b` (word boundary) работает **ТОЛЬКО с ASCII-символами** (`[a-zA-Z0-9_]`). Кириллица не считается «word character». Поэтому `\b` перед кириллическими буквами НИКОГДА не срабатывает.
+
+**Фикс:** Заменить `\b` на `(? ⚠️ **Правило:** Никогда не использовать `\b` для кириллицы в JavaScript. Всегда использовать lookbehind/lookahead.
+
+## Адаптация для другого проекта
+
+1. **Целевой CSS-класс:** Заменить `h3` в regex шага 1 на класс текстовых блоков вашего шаблона
+2. **Закрывающий паттерн:** ` ` — подстроить под структуру вашего HTML (может быть `
`, `` и т.д.)
+3. **Типограф:** Если используется типограф, regex уже обрабатывает ` `. Если нет — `(?:\s| )+` всё равно будет работать корректно (просто ` `-ветка не сработает)
+4. **Вызов:** Применять к финальному HTML **после** типографа, но **до** отдачи клиенту
+5. **Тестирование:** Обязательно проверять в Mail.ru — это самый строгий клиент по обработке пробелов
+
+## Где вызывается
+
+В бэкенде (vite.config.js), в endpoint рендера email — после получения HTML из email-gen-api и обработки Mindbox-тегов:
+
+```javascript
+const nowrapHtml = applyNowrap(rawHtml)
+const nowrapPreview = applyNowrap(mindbox.html)
+```
+
+Результаты отдаются клиенту:
+- `nowrapHtml` — финальный HTML для экспорта/отправки
+- `nowrapPreview` — HTML с подставленными товарами для превью
diff --git a/email-gen b/email-gen
new file mode 160000
index 0000000..ada0a37
--- /dev/null
+++ b/email-gen
@@ -0,0 +1 @@
+Subproject commit ada0a3716dc454846ab244296f39718d66c6ff38
diff --git a/email-gen-overrides/README.md b/email-gen-overrides/README.md
new file mode 100644
index 0000000..ef4a836
--- /dev/null
+++ b/email-gen-overrides/README.md
@@ -0,0 +1,16 @@
+# email-gen overrides
+
+This folder stores local overrides mounted into `email-gen-api` container.
+
+Scope now:
+- `reaspekt-master` only
+- mounted files:
+ - `blocks/_factory.pug`
+ - `blocks/buttons.pug`
+ - `blocks/texts.pug`
+ - `blocks/texts-ext.pug`
+ - `blocks/other-ext.pug`
+
+Why:
+- keep customizations outside `email-gen` repo
+- `git pull` in `email-gen` does not remove these local overrides
diff --git a/email-gen-overrides/reaspekt-master/blocks/_factory.pug b/email-gen-overrides/reaspekt-master/blocks/_factory.pug
new file mode 100644
index 0000000..0706812
--- /dev/null
+++ b/email-gen-overrides/reaspekt-master/blocks/_factory.pug
@@ -0,0 +1,323 @@
+// Reaspekt master shared factory mixins
+
+mixin ctaButtonSection(opts = {})
+ - const width = opts.width || 560
+ - const text = opts.text || '#ТЕКСТ#'
+ - const href = opts.href || '#ССЫЛКА#'
+ - const buttonBg = opts.buttonBg || '#ffffff'
+ - const buttonText = opts.buttonText || '#130F33'
+ - const iconSrc = opts.iconSrc || 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-white.png'
+
+ if width === 560
+ tr
+ td.padding-wrapper
+ +buttonRounded(text, href, 560, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+ else if width === 270
+ tr
+ td.padding-wrapper
+ +defaultTable('560')
+ tr
+ td(width='270')
+ +buttonRounded(text, href, 270, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+ +tdFixed(20)
+ td(width='270')
+ +buttonRounded(text, href, 270, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+ else if width === 173
+ tr
+ td.padding-wrapper
+ +defaultTable('560')
+ tr
+ td(width='173')
+ +buttonRounded(text, href, 173, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+ +tdFixed(21)
+ td(width='173')
+ +buttonRounded(text, href, 173, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+ +tdFixed(20)
+ td(width='173')
+ +buttonRounded(text, href, 173, 60, buttonBg, 16, buttonText, 0, '', iconSrc, 20, 17, 1, 'right').textVerdana
+
+mixin ctaLinkSection(opts = {})
+ - const width = opts.width || 560
+ - const href = opts.href || ''
+ - const linkText = opts.linkText || (width === 173 ? 'Любой текст' : 'Как не терять наши письма?')
+ - const colorClass = opts.colorClass || 'color__blue'
+ - const linkClass = opts.linkClass || 'text__link-blue'
+ - const linkColorClass = opts.linkColorClass || textClass
+ - const tableWidth = opts.tableWidth || '560'
+
+ if width === 560
+ tr
+ td(align='center').padding-wrapper
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+ else if width === 270
+ tr
+ td.padding-wrapper
+ +defaultTable(tableWidth)
+ tr
+ td(width='270' align='center')
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+ +tdFixed(20)
+ td(width='270' align='center')
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+ else if width === 173
+ tr
+ td.padding-wrapper
+ +defaultTable(tableWidth)
+ tr
+ td(width='173' align='center')
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+ +tdFixed(21)
+ td(width='173' align='center')
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+ +tdFixed(20)
+ td(width='173' align='center')
+ a(href=href target='_blank').textVerdana(class=`${colorClass} text__link ${linkClass}`)= linkText
+
+mixin contentCardInner(opts = {})
+ - const width = opts.width || 560
+ - const titleClass = opts.titleClass || 'color__blue'
+ - const textClass = opts.textClass || 'color__blue'
+ - const titleSizeClass = opts.titleSizeClass || 'header__h1'
+ - const title = opts.title || 'Контекстная реклама для увеличения продаж'
+ - const text = opts.text || 'Наша команда стала активнее участвовать в образовательных мероприятиях, на которых мы делимся тонкостями своей работы и рассказываем о практическом опыте. Решили поделиться с вами записями прошедших вебинаров. Вот сводка тем:'
+ - const buttonBg = opts.buttonBg || '#ffffff'
+ - const buttonText = opts.buttonText || '#130F33'
+ - const withImage = !!opts.withImage
+ - const imageSrc = opts.imageSrc || 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/banner-50-percent.jpg'
+
+ +defaultTable(`${width}`)
+ if withImage
+ tr
+ td
+ +backgroundImageBlock(imageSrc, width, 180, '#ffffff', 'left', 'top')
+ +spacerLine(20)
+ tr
+ td
+ span.textVerdana(class=`${titleSizeClass} ${titleClass}`)= title
+ +spacerLine(20)
+ if opts.firstColumnExtraGap
+ +spacerLine(20)
+ tr
+ td
+ span.textVerdana.text__normal(class=textClass)= text
+ +spacerLine(20)
+ tr
+ td
+ +buttonRounded('#ТЕКСТ#', '#ССЫЛКА#', width, 60, buttonBg, 16, buttonText, 0, '').textVerdana
+
+mixin textSection560(opts = {})
+ tr
+ td.padding-wrapper(class=opts.bgClass || 'background__white')
+ +defaultTable('560')
+ +spacerLine(40)
+ tr
+ td
+ span.textVerdana.header__h1(class=opts.titleClass || 'color__blue') Мы продолжаем делать вебинары для вас, а чтобы следить за актуальными темами, подписывайтесь на наше сообщество ВКонтакте
+ +spacerLine(20)
+ tr
+ td
+ span.textVerdana.text__normal(class=opts.textClass || 'color__blue') Наша команда стала активнее участвовать в образовательных мероприятиях, на которых мы делимся тонкостями своей работы и рассказываем о практическом опыте. Решили поделиться с вами записями прошедших вебинаров. Вот сводка тем:
+ +spacerLine(20)
+ tr
+ td
+ +buttonRounded('#ТЕКСТ#', '#ССЫЛКА#', 560, 60, opts.buttonBg || '#ffffff', 16, opts.buttonText || '#130F33', 0, '').textVerdana
+ +spacerLine(40)
+
+mixin textSection270(opts = {})
+ tr
+ td.padding-wrapper(class=opts.bgClass || 'background__white')
+ +defaultTable('560')
+ +spacerLine(40, 3)
+ tr
+ td(valign='top')
+ +contentCardInner({
+ width: 270,
+ withImage: true,
+ titleClass: opts.titleClass,
+ textClass: opts.textClass,
+ buttonBg: opts.buttonBg,
+ buttonText: opts.buttonText
+ })
+ +tdFixed(20)
+ td(valign='top')
+ +contentCardInner({
+ width: 270,
+ withImage: true,
+ titleClass: opts.titleClass,
+ textClass: opts.textClass,
+ buttonBg: opts.buttonBg,
+ buttonText: opts.buttonText
+ })
+ +spacerLine(40, 3)
+
+mixin textSection173(opts = {})
+ tr
+ td.padding-wrapper(class=opts.bgClass || 'background__white')
+ +defaultTable('560')
+ +spacerLine(40, 5)
+
+mixin textImageSection560(opts = {})
+ - const title = opts.title || 'Мы продолжаем делать вебинары для вас, а чтобы следить за актуальными темами, подписывайтесь на наше сообщество ВКонтакте'
+ - const text = opts.text || 'Наша команда стала активнее участвовать в образовательных мероприятиях, на которых мы делимся тонкостями своей работы и рассказываем о практическом опыте. Решили поделиться с вами записями прошедших вебинаров. Вот сводка тем:'
+ - const imageSrc = opts.imageSrc || 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/image.jpg'
+ - const bgClass = opts.bgClass || 'background__white'
+ - const titleClass = opts.titleClass || 'color__blue'
+ - const textClass = opts.textClass || 'color__blue'
+ - const buttonBg = opts.buttonBg || '#ffffff'
+ - const buttonText = opts.buttonText || '#130F33'
+ - const showTitle = opts.showTitle !== false
+ - const showText = opts.showText !== false
+ - const showButton = !!opts.showButton
+ - const textBeforeImage = !!opts.textBeforeImage
+ - const center = !!opts.center
+ - const linkMode = !!opts.linkMode
+ - const linkHref = opts.linkHref || ''
+ - const linkText = opts.linkText || 'Как не терять наши письма?'
+ - const linkClass = opts.linkClass || 'text__link-blue'
+
+ tr
+ td.padding-wrapper(class=bgClass)
+ +defaultTable('560')
+ +spacerLine(40)
+ if showTitle && !textBeforeImage
+ tr
+ td(class=center ? 'text__center' : '')
+ span.textVerdana.header__h1(class=titleClass)= title
+ +spacerLine(20)
+ if showText && textBeforeImage
+ tr
+ td(class=center ? 'text__center' : '')
+ span.textVerdana.text__normal(class=textClass)= text
+ +spacerLine(20)
+ tr
+ td
+ +backgroundImageBlock(imageSrc, 560, 266, '#ffffff', 'left', 'top')
+ if showText && !textBeforeImage
+ +spacerLine(20)
+ tr
+ td(class=center ? 'text__center' : '')
+ span.textVerdana.text__normal(class=textClass)= text
+ if showButton
+ +spacerLine(20)
+ tr
+ td
+ if linkMode
+ a(href=linkHref target='_blank' style='width: 100%;').textVerdana(class=`${linkColorClass} text__link ${linkClass}`)= linkText
+ else
+ +buttonRounded('#ТЕКСТ#', '#ССЫЛКА#', 560, 60, buttonBg, 16, buttonText, 0, '').textVerdana
+ +spacerLine(40)
+
+mixin contentCardImage270(opts = {})
+ - const title = opts.title || 'Контекстная реклама для увеличения продаж'
+ - const text = opts.text || 'Наша команда стала активнее участвовать в образовательных мероприятиях, на которых мы делимся тонкостями своей работы и рассказываем о практическом опыте. Решили поделиться с вами записями прошедших вебинаров. Вот сводка тем:'
+ - const imageSrc = opts.imageSrc || 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/image.jpg'
+ - const titleClass = opts.titleClass || 'color__blue'
+ - const textClass = opts.textClass || 'color__blue'
+ - const buttonBg = opts.buttonBg || '#ffffff'
+ - const buttonText = opts.buttonText || '#130F33'
+ - const showButton = !!opts.showButton
+ - const linkMode = !!opts.linkMode
+ - const linkHref = opts.linkHref || ''
+ - const linkText = opts.linkText || 'Как не терять наши письма?'
+ - const linkClass = opts.linkClass || 'text__link-blue'
+ - const linkColorClass = opts.linkColorClass || textClass
+
+ +defaultTable('270')
+ tr
+ td
+ +backgroundImageBlock(imageSrc, 270, 270, '#ffffff', 'left', 'top')
+ +spacerLine(20)
+ tr
+ td
+ span.textVerdana.header__h2(class=titleClass)= title
+ +spacerLine(20)
+ tr
+ td
+ span.textVerdana.text__normal(class=textClass)= text
+ if showButton
+ +spacerLine(20)
+ tr
+ td
+ if linkMode
+ a(href=linkHref target='_blank' style='width: 100%;').textVerdana(class=`${linkColorClass} text__link ${linkClass}`)= linkText
+ else
+ +buttonRounded('#ТЕКСТ#', '#ССЫЛКА#', 270, 60, buttonBg, 16, buttonText, 0, '').textVerdana
+
+mixin textImageSection270(opts = {})
+ - const bgClass = opts.bgClass || 'background__white'
+
+ tr
+ td.padding-wrapper(class=bgClass)
+ +defaultTable('560')
+ +spacerLine(40, 3)
+ tr
+ td(valign='top')
+ +contentCardImage270(opts)
+ +tdFixed(20)
+ td(valign='top')
+ +contentCardImage270(opts)
+ +spacerLine(40, 3)
+ tr
+ td(valign='top')
+ +contentCardInner({
+ width: 173,
+ withImage: false,
+ titleSizeClass: 'header__h2',
+ titleClass: opts.titleClass,
+ textClass: opts.textClass,
+ buttonBg: opts.buttonBg,
+ buttonText: opts.buttonText,
+ firstColumnExtraGap: !!opts.firstColumnExtraGap
+ })
+ +tdFixed(20)
+ td(valign='top')
+ +contentCardInner({
+ width: 173,
+ withImage: false,
+ titleSizeClass: 'header__h2',
+ titleClass: opts.titleClass,
+ textClass: opts.textClass,
+ buttonBg: opts.buttonBg,
+ buttonText: opts.buttonText
+ })
+ +tdFixed(21)
+ td(valign='top')
+ +contentCardInner({
+ width: 173,
+ withImage: false,
+ titleSizeClass: 'header__h2',
+ titleClass: opts.titleClass,
+ textClass: opts.textClass,
+ buttonBg: opts.buttonBg,
+ buttonText: opts.buttonText
+ })
+ +spacerLine(40, 5)
+
+mixin sideImageTextSection(opts = {})
+ - const bgClass = opts.bgClass || 'background__blue'
+ - const textClass = opts.textClass || 'color__white'
+ - const imageSrc = opts.imageSrc || 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/icons-box-blue.png'
+ - const imageBg = opts.imageBg || '#130F33'
+ - const text1 = opts.text1 || 'Искусственный интеллект может ускорить работу SEO-специалистов и оптимизировать затраты. Заменяет ли chatGPT копирайтера? Всем ли поможет такой подход? Об этом и не только узнайте по ссылке.'
+ - const text2 = opts.text2 || 'Наша команда стала активнее участвовать в образовательных мероприятиях, на которых мы делимся тонкостями своей работы....'
+ - const showSecondText = opts.showSecondText !== false
+
+ tr
+ td(class=bgClass)
+ +defaultTable('100%')
+ tr
+ td.paddingWrapper
+ +defaultTable('100%')
+ +spacerLine(40)
+ tr
+ td
+ span.textVerdana.text__normal(class=textClass)!= text1
+ if showSecondText
+ tr
+ td
+ span.textVerdana.text__normal(class=textClass)!= text2
+ +spacerLine(40)
+ td(valign='bottom')
+ +defaultTable('')
+ +trtd
+ +backgroundImageBlock(imageSrc, 145, 270, imageBg, 'center', 'top', 'contain')
diff --git a/email-gen-overrides/reaspekt-master/blocks/buttons.pug b/email-gen-overrides/reaspekt-master/blocks/buttons.pug
new file mode 100644
index 0000000..e839a9a
--- /dev/null
+++ b/email-gen-overrides/reaspekt-master/blocks/buttons.pug
@@ -0,0 +1,77 @@
+include ./_factory
+
++spacerLine(20)
+//Кнопка Синяя 100% ширины
++ctaButtonSection({
+ width: 560,
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-white.png'
+})
++spacerLine(20)
+//Кнопка Синяя 50% ширины
++ctaButtonSection({
+ width: 270,
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-white.png'
+})
++spacerLine(20)
+//Кнопка Синяя 33% ширины
++ctaButtonSection({
+ width: 173,
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-white.png'
+})
++spacerLine(20)
+
++spacerLine(20)
+//Кнопка Зеленая 100% ширины
++ctaButtonSection({
+ width: 560,
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-blue.png'
+})
++spacerLine(20)
+//Кнопка Зеленая 50% ширины
++ctaButtonSection({
+ width: 270,
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-blue.png'
+})
++spacerLine(20)
+//Кнопка Зеленая 33% ширины
++ctaButtonSection({
+ width: 173,
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ iconSrc: 'https://574922.selcdn.ru/email.static/reaspekt/2024_newsletters/2024_09_29/icon-watch-blue.png'
+})
+
++spacerLine(20)
+//Сcылка синяя 100% ширины
++ctaLinkSection({ width: 560, colorClass: 'color__blue', linkClass: 'text__link-blue' })
++spacerLine(20)
+//Сcылка синяя 50% ширины
++ctaLinkSection({ width: 270, colorClass: 'color__blue', linkClass: 'text__link-blue' })
++spacerLine(20)
+//Сcылка синяя 33% ширины
++ctaLinkSection({ width: 173, colorClass: 'color__blue', linkClass: 'text__link-blue' })
++spacerLine(20)
+
+tr
+ td.background__blue
+ +defaultTable('560')
+ +spacerLine(20)
+ //Сcылка белая 100% ширины
+ +ctaLinkSection({ width: 560, colorClass: 'color__white', linkClass: 'text__link-white' })
+ +spacerLine(20)
+ //Сcылка белая 50% ширины
+ +ctaLinkSection({ width: 270, colorClass: 'color__white', linkClass: 'text__link-white', tableWidth: '100%' })
+ +spacerLine(20)
+ //Сcылка белая 33% ширины
+ +ctaLinkSection({ width: 173, colorClass: 'color__white', linkClass: 'text__link-white', tableWidth: '100%' })
+ +spacerLine(20)
diff --git a/email-gen-overrides/reaspekt-master/blocks/other-ext.pug b/email-gen-overrides/reaspekt-master/blocks/other-ext.pug
new file mode 100644
index 0000000..2ab5fc7
--- /dev/null
+++ b/email-gen-overrides/reaspekt-master/blocks/other-ext.pug
@@ -0,0 +1,29 @@
+include ./_factory
+
+// Extended
+
+// 1
+
+// Текст с изображением справа Синий Фон
++sideImageTextSection({
+ bgClass: 'background__blue',
+ textClass: 'color__white',
+ imageSrc: 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/icons-box-blue.png',
+ imageBg: '#130F33'
+})
+
+// Текст с изображением справа Белый Фон
++sideImageTextSection({
+ bgClass: 'background__white',
+ textClass: 'color__blue',
+ imageSrc: 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/icons-box-white.png',
+ imageBg: '#ffffff'
+})
+
+// Текст с изображением справа Зеленый Фон
++sideImageTextSection({
+ bgClass: 'background__green',
+ textClass: 'color__blue',
+ imageSrc: 'https://574922.selcdn.ru/email.static/reaspekt/master-tamplate/banners/icons-box-green.png',
+ imageBg: '#AAC8C8'
+})
diff --git a/email-gen-overrides/reaspekt-master/blocks/texts-ext.pug b/email-gen-overrides/reaspekt-master/blocks/texts-ext.pug
new file mode 100644
index 0000000..3ccb0cb
--- /dev/null
+++ b/email-gen-overrides/reaspekt-master/blocks/texts-ext.pug
@@ -0,0 +1,222 @@
+include ./_factory
+
+//Перенести в texts.pug
+
+//Текст 100% Ширины + Картинка Синий фон
++textImageSection560({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ showTitle: true,
+ showText: true,
+ showButton: true
+})
+
+//Текст 100% Ширины + Картинка Белый фон
++textImageSection560({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showTitle: true,
+ showText: true,
+ showButton: true
+})
+
+//Текст 100% Ширины + Картинка Зеленый фон
++textImageSection560({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showTitle: true,
+ showText: true,
+ showButton: true
+})
+
+//Extended
+
+//1
+
+//Текст 100% Ширины + Картинка Синий фон
++textImageSection560({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: false
+})
+
+//Текст 100% Ширины + Картинка Белый фон
++textImageSection560({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: false
+})
+
+//Текст 100% Ширины + Картинка Зеленый фон
++textImageSection560({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: false
+})
+
+//2
+
+//Текст 100% Ширины + Картинка Синий фон
++textImageSection560({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: true
+})
+
+//Текст 100% Ширины + Картинка Белый фон
++textImageSection560({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: true
+})
+
+//Текст 100% Ширины + Картинка Зеленый фон
++textImageSection560({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showTitle: false,
+ showText: true,
+ textBeforeImage: true,
+ showButton: true
+})
+
+//3
+
+//Текст 100% Ширины + Картинка Синий фон
++textImageSection560({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ title: 'Контекстная реклама для увеличения продаж',
+ center: true,
+ showTitle: true,
+ showText: true,
+ showButton: false
+})
+
+//Текст 100% Ширины + Картинка Белый фон
++textImageSection560({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ title: 'Контекстная реклама для увеличения продаж',
+ center: true,
+ showTitle: true,
+ showText: true,
+ showButton: false
+})
+
+//Текст 100% Ширины + Картинка Зеленый фон
++textImageSection560({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ title: 'Контекстная реклама для увеличения продаж',
+ center: true,
+ showTitle: true,
+ showText: true,
+ showButton: false
+})
+
+//4
+
+//Текст 50% Ширины + Картинка Синий фон
++textImageSection270({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8',
+ showButton: true
+})
+
+//Текст 50% Ширины + Картинка Белый фон
++textImageSection270({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showButton: true
+})
+
+//Текст 50% Ширины + Картинка Зеленый фон
++textImageSection270({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ showButton: true
+})
+
+//5
+
+//Текст 50% Ширины + Картинка Синий фон
++textImageSection270({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ showButton: true,
+ linkMode: true,
+ linkColorClass: 'color__green',
+ linkClass: 'text__link-green'
+})
+
+//Текст 50% Ширины + Картинка Белый фон
++textImageSection270({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ showButton: true,
+ linkMode: true,
+ linkColorClass: 'color__green',
+ linkClass: 'text__link-green'
+})
+
+//Текст 50% Ширины + Картинка Зеленый фон
++textImageSection270({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ showButton: true,
+ linkMode: true,
+ linkColorClass: 'color__blue',
+ linkClass: 'text__link-blue'
+})
diff --git a/email-gen-overrides/reaspekt-master/blocks/texts.pug b/email-gen-overrides/reaspekt-master/blocks/texts.pug
new file mode 100644
index 0000000..2f4ac05
--- /dev/null
+++ b/email-gen-overrides/reaspekt-master/blocks/texts.pug
@@ -0,0 +1,83 @@
+include ./_factory
+
+//Текст 100% Ширины Синий фон
++textSection560({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8'
+})
+
+//Текст 100% Ширины Белый фон
++textSection560({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33'
+})
+
+//Текст 100% Ширины Зеленый фон
++textSection560({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33'
+})
+
+//Текст 50% Ширины Синий фон
++textSection270({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8'
+})
+
+//Текст 50% Ширины Белый фон
++textSection270({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33'
+})
+
+//Текст 50% Ширины Зеленый фон
++textSection270({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33'
+})
+
+//Текст 33% Ширины Синий фон
++textSection173({
+ bgClass: 'background__blue',
+ titleClass: 'color__white',
+ textClass: 'color__white',
+ buttonBg: '#130F33',
+ buttonText: '#AAC8C8'
+})
+
+//Текст 33% Ширины Белый фон
++textSection173({
+ bgClass: 'background__white',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33',
+ firstColumnExtraGap: true
+})
+
+//Текст 33% Ширины Зеленый фон
++textSection173({
+ bgClass: 'background__green',
+ titleClass: 'color__blue',
+ textClass: 'color__blue',
+ buttonBg: '#ffffff',
+ buttonText: '#130F33'
+})
diff --git a/z51-pug-builder/.dockerignore b/z51-pug-builder/.dockerignore
new file mode 100644
index 0000000..0ca39c0
--- /dev/null
+++ b/z51-pug-builder/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.DS_Store
diff --git a/z51-pug-builder/.gitignore b/z51-pug-builder/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/z51-pug-builder/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/z51-pug-builder/.vscode/extensions.json b/z51-pug-builder/.vscode/extensions.json
new file mode 100644
index 0000000..bdef820
--- /dev/null
+++ b/z51-pug-builder/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["svelte.svelte-vscode"]
+}
diff --git a/z51-pug-builder/Dockerfile b/z51-pug-builder/Dockerfile
new file mode 100644
index 0000000..2f6c2e5
--- /dev/null
+++ b/z51-pug-builder/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY z51-pug-builder/package*.json ./
+RUN npm install
+
+COPY z51-pug-builder/ ./
+COPY email-gen/ /email-gen/
+
+EXPOSE 5173
+
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
diff --git a/z51-pug-builder/README.md b/z51-pug-builder/README.md
new file mode 100644
index 0000000..51185dd
--- /dev/null
+++ b/z51-pug-builder/README.md
@@ -0,0 +1,243 @@
+# Z51 Pug Builder — руководство по интерфейсу
+
+Этот файл описывает **интерфейс** и **логику использования** конструктора писем. Здесь нет технических деталей реализации — только то, как устроена работа в UI, где что находится и зачем это нужно.
+
+---
+
+## 1. Общая идея
+
+Конструктор собирает письмо из блоков, взятых из `block.pug`. Каждый блок — это кусок Pug-кода, размеченный комментарием `//Название`. В интерфейсе вы выбираете нужные блоки, редактируете только важные поля (текст, ссылки, изображения, ID и т.п.), а на выходе получаете собранный Pug-файл.
+
+Главная выгода: быстро собирать письма без ручного копирования и без риска повредить структуру шаблона.
+
+---
+
+## 2. Навигация по интерфейсу
+
+Верхняя панель (шапка) содержит:
+- **Иконка дискеты** — открывает поле для названия пресета и сохранение пресета.
+- **Иконка папки** — переход на страницу пресетов.
+- **Сборка** — основная страница сборки письма.
+- **Настройки** — управление проектом и шаблонами.
+- **Переключатель темы** — светлая/тёмная.
+- **Назад / Сброс** — только на странице сборки.
+
+В левом сайдбаре:
+- **Название проекта** (крупно, окрашено акцентом) или логотип.
+- **Выбор проекта**.
+- **Добавить блок** (выпадающий список).
+- **Быстрые кнопки блоков**.
+- Счётчик общего количества блоков.
+
+---
+
+## 3. Страница «Сборка»
+
+Это основной экран для работы с письмом.
+
+### 3.1. Добавление блоков
+- Через выпадающий список «Добавить блок».
+- Через быстрые кнопки (настраиваются в настройках проекта).
+- Через кнопку «+» внизу каждого блока — добавляет новый блок **после текущего**.
+
+Новые блоки открываются сразу (развернуты).
+
+---
+
+### 3.2. Карточка блока
+Каждый блок — это карточка с:
+- названием блока;
+- кнопками **вверх / вниз / удалить**;
+- полями редактирования;
+- чекбоксом «Отступ после блока» + поле значения (если включено);
+- кнопками «PUG» и «+» (добавление блока после).
+
+В любой момент можно раскрыть/свернуть блок по клику на его заголовок.
+
+---
+
+### 3.3. Поля редактирования
+Интерфейс не показывает весь код, а только полезные поля.
+
+Типы полей:
+- **Текст** — содержимое внутри строк Pug.
+- **Ссылки** — `href`.
+- **Картинки** — `src`.
+- **Кнопки** — если в блоке есть `+buttonRounded(...)`, показываются отдельные поля для текста и ссылки.
+- **Товары** — если есть `+productsX`, показывается поле «ID товаров».
+
+Поля автоматически определяются из шаблона блока, но их можно включать/выключать и переименовывать в настройках.
+
+---
+
+### 3.4. Форматирование текста
+Для текстовых полей есть панель действий:
+- **T** — типограф.
+- **Ж** — жирный текст.
+- **•** — маркер.
+- **** / ** ** — списки.
+- **A + URL** — ссылка.
+- **Глаз** — предпросмотр HTML.
+
+---
+
+### 3.5. Абзацы в блоке «Текст»
+Блок «Текст» поддерживает несколько абзацев:
+- Кнопка **«Абзац»** добавляет новый абзац.
+- У каждого дополнительного абзаца есть кнопка удаления.
+- Между абзацами автоматически вставляется `+spacerLine(20)`.
+
+---
+
+### 3.6. Списки (нумерованные и маркированные)
+Для блоков списков:
+- Редактируются только **тексты пунктов**.
+- Картинки маркеров/цифр скрыты и не редактируются.
+- Кнопка **«Пункт»** добавляет новый пункт.
+- Кнопка **×** удаляет пункт.
+
+При добавлении блока со списком создаётся только **первый пункт**, остальные добавляются вручную.
+
+---
+
+### 3.7. Отступы
+- **Глобальный отступ** задаётся в настройках.
+- У каждого блока можно включить/выключить отступ и задать своё значение.
+- Для некоторых блоков отступ не добавляется автоматически (Разделитель, Отступ 20/40).
+
+---
+
+### 3.8. Итоговый Pug
+Правый блок — это итоговый Pug-код:
+- он обновляется при любом изменении;
+- можно **копировать** или **скачивать**;
+- при редактировании блок подсвечивается и прокручивается в Pug-окне.
+
+---
+
+## 4. Страница «Настройки»
+
+Настройки делятся на вкладки:
+
+### 4.1. Общие
+**Источник блоков**
+- Загрузка нового `block.pug`.
+- Перезагрузка текущего источника.
+
+**Поля блока**
+- Выбор любого блока.
+- Включение/выключение полей.
+- Переименование подписи поля.
+
+---
+
+### 4.2. Глобальные блоки
+- Список всех блоков проекта.
+- Для каждого блока можно редактировать Pug-шаблон напрямую.
+- Настроить отступ по умолчанию для конкретного блока.
+
+---
+
+### 4.3. Текущий проект
+Настройки внешнего вида проекта:
+- Название проекта (в левом верхнем углу).
+- Цвет акцентов (через палитру или HEX).
+- Логотип проекта (загрузка изображения).
+
+Логотип хранится в настройках проекта.
+
+---
+
+### 4.4. Быстрые блоки
+- Выбор блоков, которые появятся слева как быстрые кнопки.
+- Можно добавлять/удалять элементы.
+
+---
+
+### 4.5. Новый проект
+- Создание нового проекта.
+- Каждый проект имеет собственный `block.pug`, настройки, пресеты и сборки.
+
+---
+
+## 5. Пресеты
+
+Пресет — это сохранённая сборка письма.
+
+### Как работают пресеты
+- Сохраняются через иконку дискеты в шапке.
+- Открываются через страницу «Пресеты».
+- При сохранении, если имя уже существует — система спрашивает, **обновить или создать новый**.
+- При открытии пресета его имя автоматически подставляется в поле сохранения.
+
+### На странице пресетов
+- Отображаются название и дата/время сохранения.
+- Есть поиск по названию.
+- Есть сортировка: сначала новые / сначала старые.
+
+---
+
+## 6. Подсказки и наведение
+
+Все кнопки и поля имеют всплывающие подсказки (`title`), чтобы быстро понимать назначение элемента.
+
+---
+
+## 7. Выгоды использования конструктора
+
+- **Быстрее сборки** — письмо собирается за минуты.
+- **Нет ошибок в структуре Pug** — редактируются только нужные фрагменты.
+- **Один интерфейс для всех проектов**.
+- **Удобное сохранение и повторное использование** через пресеты.
+- **Гибкая настройка под разные команды и бренды**.
+
+---
+
+## 8. Типовые сценарии
+
+### 8.1. Быстрая сборка письма с нуля
+1) Перейдите в «Сборка».
+2) Добавьте блоки через быстрые кнопки или выпадающий список.
+3) Заполните поля (текст, ссылки, ID товаров).
+4) Проверьте итоговый Pug справа.
+5) Скопируйте или скачайте итоговый файл.
+
+### 8.2. Сборка письма по шаблону (пресету)
+1) Откройте страницу «Пресеты».
+2) Найдите нужный пресет через поиск.
+3) Нажмите «Открыть».
+4) Отредактируйте детали и сохраните обратно.
+
+### 8.3. Обновление существующего пресета
+1) Откройте пресет.
+2) Внесите изменения.
+3) Нажмите иконку дискеты — имя пресета подставится автоматически.
+4) Подтвердите обновление в диалоге.
+
+### 8.4. Настройка быстрых блоков
+1) Перейдите в «Настройки» → «Быстрые блоки».
+2) Выберите нужные блоки и нажмите «Добавить».
+3) Удалите ненужные кнопки через «×».
+4) Эти кнопки появятся слева в «Сборке».
+
+### 8.5. Добавление абзацев в текстовом блоке
+1) Откройте блок «Текст».
+2) Нажмите «Абзац».
+3) Заполните новый пункт текста.
+4) При необходимости удалите абзац кнопкой «×».
+
+### 8.6. Работа со списками
+1) Добавьте блок «Маркированный список» или «Нумерованный список».
+2) Заполните первый пункт.
+3) Добавьте новые пункты кнопкой «Пункт».
+4) Удалите лишние пункты кнопкой «×».
+
+### 8.7. Создание нового проекта
+1) Откройте «Настройки» → «Новый проект».
+2) Введите имя проекта, нажмите «Добавить».
+3) Загрузите `block.pug` для нового проекта.
+4) Настройте быстрые блоки, цвета и поля.
+
+---
+
+Если нужно — могу добавить в интерфейс отдельную страницу «Справка» с этим текстом.
diff --git a/z51-pug-builder/index.html b/z51-pug-builder/index.html
new file mode 100644
index 0000000..21eaad3
--- /dev/null
+++ b/z51-pug-builder/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+ ASPEKTER
+
+
+
+
+
+
diff --git a/z51-pug-builder/jsconfig.json b/z51-pug-builder/jsconfig.json
new file mode 100644
index 0000000..c7a0b10
--- /dev/null
+++ b/z51-pug-builder/jsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "module": "ESNext",
+ /**
+ * svelte-preprocess cannot figure out whether you have
+ * a value or a type, so tell TypeScript to enforce using
+ * `import type` instead of `import` for Types.
+ */
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ /**
+ * To have warnings / errors of the Svelte compiler at the
+ * correct position, enable source maps by default.
+ */
+ "sourceMap": true,
+ "esModuleInterop": true,
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable this if you'd like to use dynamic types.
+ */
+ "checkJs": true
+ },
+ /**
+ * Use global.d.ts instead of compilerOptions.types
+ * to avoid limiting type declarations.
+ */
+ "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
+}
diff --git a/z51-pug-builder/package-lock.json b/z51-pug-builder/package-lock.json
new file mode 100644
index 0000000..30dd1a2
--- /dev/null
+++ b/z51-pug-builder/package-lock.json
@@ -0,0 +1,2051 @@
+{
+ "name": "va-aspekter",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "va-aspekter",
+ "version": "0.0.0",
+ "dependencies": {
+ "@yonote/js-sdk": "^0.1.1",
+ "basic-ftp": "^5.0.5",
+ "ssh2-sftp-client": "^11.0.0"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "svelte": "^5.43.8",
+ "vite": "^7.2.4"
+ }
+ },
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
+ "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+ "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+ "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+ "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+ "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+ "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+ "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+ "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+ "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+ "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+ "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+ "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+ "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+ "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+ "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+ "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+ "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+ "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+ "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+ "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+ "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+ "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
+ "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "fflate": "^0.8.2",
+ "token-types": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@yonote/js-sdk": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@yonote/js-sdk/-/js-sdk-0.1.1.tgz",
+ "integrity": "sha512-yq/3bCAM3lVBlJVQ8yB8IsR6CO0wH5JMPWT+zTQYmFvPOB6epDFl2rBxB9g5kf74h9BHOh1c/eSpPu/MRTSJEg==",
+ "license": "MIT",
+ "dependencies": {
+ "axios": "1.12.0",
+ "file-type": "21.0.0",
+ "uuid": "11.1.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
+ "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/basic-ftp": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
+ "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/buildcheck": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+ "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/cpu-features": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+ "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "buildcheck": "~0.0.6",
+ "nan": "^2.19.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
+ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
+ "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
+ "node_modules/file-type": {
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz",
+ "integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.2.7",
+ "strtok3": "^10.2.2",
+ "token-types": "^6.0.0",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nan": {
+ "version": "2.25.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
+ "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "license": "MIT",
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.57.1",
+ "@rollup/rollup-android-arm64": "4.57.1",
+ "@rollup/rollup-darwin-arm64": "4.57.1",
+ "@rollup/rollup-darwin-x64": "4.57.1",
+ "@rollup/rollup-freebsd-arm64": "4.57.1",
+ "@rollup/rollup-freebsd-x64": "4.57.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+ "@rollup/rollup-linux-arm64-musl": "4.57.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+ "@rollup/rollup-linux-loong64-musl": "4.57.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-musl": "4.57.1",
+ "@rollup/rollup-openbsd-x64": "4.57.1",
+ "@rollup/rollup-openharmony-arm64": "4.57.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+ "@rollup/rollup-win32-x64-gnu": "4.57.1",
+ "@rollup/rollup-win32-x64-msvc": "4.57.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ssh2": {
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+ "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "asn1": "^0.2.6",
+ "bcrypt-pbkdf": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ },
+ "optionalDependencies": {
+ "cpu-features": "~0.0.10",
+ "nan": "^2.23.0"
+ }
+ },
+ "node_modules/ssh2-sftp-client": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-11.0.0.tgz",
+ "integrity": "sha512-lOjgNYtioYquhtgyHwPryFNhllkuENjvCKkUXo18w/Q4UpEffCnEUBfiOTlwFdKIhG1rhrOGnA6DeKPSF2CP6w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "concat-stream": "^2.0.0",
+ "promise-retry": "^2.0.1",
+ "ssh2": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=18.20.4"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://square.link/u/4g7sPflL"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strtok3": {
+ "version": "10.3.4",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
+ "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.49.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
+ "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.2",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/token-types": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+ "license": "Unlicense"
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/z51-pug-builder/package.json b/z51-pug-builder/package.json
new file mode 100644
index 0000000..ab84554
--- /dev/null
+++ b/z51-pug-builder/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "va-aspekter",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@yonote/js-sdk": "^0.1.1",
+ "basic-ftp": "^5.0.5",
+ "ssh2-sftp-client": "^11.0.0"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "svelte": "^5.43.8",
+ "vite": "^7.2.4"
+ }
+}
diff --git a/z51-pug-builder/public/Block.pug b/z51-pug-builder/public/Block.pug
new file mode 100644
index 0000000..87257d0
--- /dev/null
+++ b/z51-pug-builder/public/Block.pug
@@ -0,0 +1,156 @@
+//Заголовок зеленый
+tr
+ td.paddingWrapperBig
+ +defaultTable("100%")
+ tr
+ td(align="center")
+ span.text.smallHeader.bold.greenText Конитива, герой!
+
+
+//Заголовок серый
+tr
+ td.paddingWrapperBig
+ +defaultTable("100%")
+ tr
+ td(align="center")
+ span.text.smallHeader.bold.groceryText Конитива, герой!
+
+
+//Текст
+tr
+ td.paddingWrapperBig
+ +defaultTable("100%")
+ tr
+ td
+ span.text.groceryText Пока одни вспоминают старую «Якудзу-3» с ее бесконечными блоками и симулятором няньки, студия Ryu Ga Gotoku Studio готовит полноценную революцию!
+
+//Доп. текст
+ +spacerLine(20)
+ tr
+ td
+ span.text.groceryText Минимальные 1080p / 30 FPS (с FSR) Проц: Intel i3-8100 / AMD Ryzen 3 2300X Видяха: NVIDIA GTX 1650 / AMD RX 6400 Оперативочка: 8 ГБ
+
+
+
+
+
+//Отступ 20
++spacerLine(20)
+
+
+//Отступ 40
++spacerLine(40)
+
+//3 товара в ряд
++products3inRow({
+ '144839': {
+ imageSrc: '',
+ name: 'BAD BUNNY',
+ category: 'Осторожно, этот кролик плохой)',
+ },
+ '142672': {
+ imageSrc: '',
+ name: 'MONARCH',
+ category: 'Ролекс среди кресел',
+ },
+ '140228': {
+ imageSrc: '',
+ name: 'Kitty Meow',
+ category: 'Кошечка делает мур-р-р!',
+ },
+})
+
+
+//Разделитель
++dividerZ(525, 2)
++spacerLine(40)
+
+//Банер
+tr
+ td(align="center")
+ a(href="https://z51.ru" target="_blank")
+ img(src="https://z51.ru/upload/email/newsletter-2026/20-01-2026/1.jpg" alt="pic" style="display: block" width="600")
+
+
+
+//Кнопка
+tr
+ td(align="center").paddingWrapper
+ +buttonRounded("Смотреть топовые ПК", "https://z51.ru/catalog/gaming-pc/", 525, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
+
+//Две кнопки
+tr
+ td.paddingWrapper
+ +defaultTable("100%")
+ tr
+ td(width="250")
+ +defaultTable("250")
+ tr
+ td(align="center")
+ +buttonRounded("Игровые кресла", "https://z51.ru/catalog/kresla/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
+ +tdFixed(36)
+ td(width="250")
+ +defaultTable("250")
+ tr
+ td(align="center")
+ +buttonRounded("Эргономичные кресла", "https://z51.ru/catalog/ergonomic-office-chairs/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905", 4).bold.text
+
+
+//Блок преимуществ
+tr
+ td.paddingWrapperBig
+ +defaultTable("100%")
+ tr
+ td(align="center")
+ span.text.smallHeader.bold.greenText Почему выбирают товары у Баззи?
+
++spacerLine(40)
+
+tr
+ td.paddingWrapper
+ +defaultTable("100%")
+ tr
+ td(width="250" valign="top")
+ +defaultTable("250")
+ //Unordered List
+ tr
+ td
+ +defaultTable("100%")
+ tr
+ +tdFixed(12, "center", "top").markerPadding
+ img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+ +tdFixed(10)
+ td
+ span.groceryText Официальный магазин В наличии всё самое вкусное от ZONE 51 — кресла, столы, периферия и аксессуары
+ +spacerLine(20)
+ tr
+ +tdFixed(12, "center", "top").markerPadding
+ img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+ +tdFixed(10)
+ td
+ span.groceryText Первоклассные и надежные продукты Из качественных, инопланетных и вроде как безопасных материалов для себя, родных и друзей. Не понравилось? Можешь вернуть в течение 28 дней с даты приобретения
+ +tdFixed(36)
+ td(width="250" valign="top")
+ +defaultTable("250")
+ tr
+ td
+ +defaultTable("100%")
+ tr
+ +tdFixed(12, "center", "top").markerPadding
+ img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+ +tdFixed(10)
+ td
+ span.groceryText Новинки и эксклюзивы Я постоянно потею над новыми товарами, которые можно приобрести только здесь
+ +spacerLine(20)
+ tr
+ +tdFixed(12, "center", "top").markerPadding
+ img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+ +tdFixed(10)
+ td
+ span.groceryText Клиенто-ориентированность Даю до 3 лет гарантии на свой товар +1 год за покупку в фирменном магазине ZONE 51 (онлайн и офлайн), а человеки у трубки помогут быстро обкашлять любые вопросы
++spacerLine(40)
+
+tr
+ td(align="center").paddingWrapper
+ +buttonRounded("Залетай к нам!", "https://z51.ru/", 300, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
+//Конец блока преимуществ
\ No newline at end of file
diff --git a/z51-pug-builder/public/favicon.jpg b/z51-pug-builder/public/favicon.jpg
new file mode 100644
index 0000000..ab74f63
Binary files /dev/null and b/z51-pug-builder/public/favicon.jpg differ
diff --git a/z51-pug-builder/public/login-bg/2.jpg b/z51-pug-builder/public/login-bg/2.jpg
new file mode 100644
index 0000000..318a79d
Binary files /dev/null and b/z51-pug-builder/public/login-bg/2.jpg differ
diff --git a/z51-pug-builder/public/login-bg/3.jpg b/z51-pug-builder/public/login-bg/3.jpg
new file mode 100644
index 0000000..d5bc638
Binary files /dev/null and b/z51-pug-builder/public/login-bg/3.jpg differ
diff --git a/z51-pug-builder/public/login-bg/4.jpg b/z51-pug-builder/public/login-bg/4.jpg
new file mode 100644
index 0000000..a09dc72
Binary files /dev/null and b/z51-pug-builder/public/login-bg/4.jpg differ
diff --git a/z51-pug-builder/public/login-bg/5.jpg b/z51-pug-builder/public/login-bg/5.jpg
new file mode 100644
index 0000000..fd4a29f
Binary files /dev/null and b/z51-pug-builder/public/login-bg/5.jpg differ
diff --git a/z51-pug-builder/public/vite.svg b/z51-pug-builder/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/z51-pug-builder/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/App.svelte b/z51-pug-builder/src/App.svelte
new file mode 100644
index 0000000..49eef22
--- /dev/null
+++ b/z51-pug-builder/src/App.svelte
@@ -0,0 +1,6975 @@
+
+
+
+
+{#if !authChecked}
+
+{:else if !currentUser}
+
+{:else}
+
+
+
+
+
+
+
+ {#if activePage === 'builder'}
+ {#if loadError}
+
{loadError}
+ {/if}
+
+
+
+ {#if idPoolOpen}
+
+
+
+ {#if idPoolQueue.length}
+
+ {#each idPoolQueue as entry}
+ {entry.value}
+ {/each}
+
+ {/if}
+
+ {/if}
+ {#if certEditingMode}
+
+
Редактирование сертификата
+
+ Сохранить сертификат
+ Отмена
+
+
+ {/if}
+ {#if assembledBlocks.length === 0}
+
Добавь блоки слева, чтобы начать сборку.
+ {#if !certEditingMode}
+
+ {#each quickBlocksView as quickName, i}
+ addQuickBlockByName(quickName)}
+ on:dragstart|stopPropagation={() => qbDragStart(i)}
+ on:dragover|preventDefault|stopPropagation={() => qbDragOver(i)}
+ on:dragend={() => qbDragEnd()}
+ title={quickName}
+ >
+ {@html getQuickBlockIcon(quickName)}
+
+ {/each}
+ { blockDropdownOpen = !blockDropdownOpen; blockDropdownSearch = '' }}>+ Добавить блок
+
+ {/if}
+ {:else}
+
+ {typografAllLoading ? 'Типограф...' : 'Типограф'}
+
+
+ {#each assembledBlocks as block, index (block.id)}
+
handleBlockDragStart(e, index)}
+ on:dragover={(e) => handleBlockDragOver(e, index)}
+ on:drop={(e) => handleBlockDrop(e, index)}
+ on:dragend={handleBlockDragEnd}
+ >
+
+
+ {#if !block.collapsed}
+ {@const _parsed = parseSections(block.content)}
+ {@const _allFields = applySchemaSettings(ensureSchema(block), block.name)}
+ {@const _secHeaders = buildSectionHeaders(_allFields, _parsed)}
+
+ {#each _allFields as field, fieldIndex (fieldKey(block, field))}
+ {#if _secHeaders.has(fieldIndex) && _secHeaders.get(fieldIndex).si > 0}
+
+ {/if}
+ {@const key = fieldKey(block, field)}
+ {@const _sh = _secHeaders.get(fieldIndex)}
+ {@const _prevSectionLabel = _sh ? _sh.section.label : ''}
+
+
+ {#if (field.type !== 'text' && field.type !== 'list-item') && (!_prevSectionLabel || field.label.toLowerCase() !== _prevSectionLabel.toLowerCase())}
+
+ {field.label}
+
+ {/if}
+ {#if field.type === 'text' || field.type === 'list-item'}
+
+
applyTypograf(block, field)}
+ disabled={typografStatus[key]?.loading}
+ title="Типограф"
+ >
+ {typografStatus[key]?.loading ? '...' : 'T'}
+
+
applyNowrapSpaces(block, field)} title="Неразрывные пробелы (nowrap)">↵
+ {#if block.name === 'Текст' && fieldIndex === 0}
+
addTextParagraph(block)}>
+ Абзац
+
+ {/if}
+ {#if block.name === 'Текст' && field.removable}
+
removeTextParagraph(block, field)} aria-label="Удалить абзац">
+ ×
+
+ {/if}
+ {#if isListBlock(block.name) && fieldIndex === 0}
+
addListItem(block)}>
+ Пункт
+
+ {/if}
+ {#if isListBlock(block.name) && field.removable}
+
removeListItem(block, field)} aria-label="Удалить пункт">
+ ×
+
+ {/if}
+
wrapBold(block, field)}>Ж
+
insertBullet(block, field)}>•
+
insertBr(block, field)} title="Вставить перенос строки">br
+
+ insertLink(block, field)}>A
+ updateLinkDraft(key, event.target.value)}
+ />
+
+
togglePreview(key)} aria-label="Предпросмотр">
+
+
+
+
+
+ {#if _sh}
+
+ moveSectionInBlock(block.id, _sh.si, -1)} title="Переместить вверх">↑
+ moveSectionInBlock(block.id, _sh.si, 1)} title="Переместить вниз">↓
+ removeSectionFromBlock(block.id, _sh.si)} title="Удалить секцию">×
+
+ {/if}
+
+ {/if}
+
+ {#if field.type === 'raw'}
+
+ {:else if isImageField(field)}
+
handleImageDragOver(e, block, field)}
+ on:dragleave={handleImageDragLeave}
+ on:drop|preventDefault={(e) => handleImageDrop(e, block, field)}>
+
updateField(block, field, event.target.value)}
+ on:focus={() => setActiveBlock(block.id)}
+ placeholder="https://..."
+ />
+ {#if settings.ftpConfig?.host}
+
openFtpGallery(block, field)}>
+
+
+ {/if}
+ {#if uploadingFields[key]?.loading}
Загрузка… {/if}
+
+ {#if uploadingFields[key]?.error}
{uploadingFields[key].error}
{/if}
+ {#if looksLikeImageUrl(getFieldValue(block, field))}
+
{ e.target.style.display = 'none' }}
+ on:load={(e) => { e.target.style.display = 'block' }} />
+ {/if}
+ {:else if field.type === 'mixin-ids'}
+
updateField(block, field, event.target.value)}
+ on:focus={() => setActiveBlock(block.id)}
+ placeholder="ID через запятую"
+ />
+ {:else if getFieldValue(block, field).length > 80 || field.type === 'text'}
+
+ {:else}
+
updateField(block, field, event.target.value)}
+ on:keydown={(e) => { if (field.type === 'mixin-text' || field.type === 'text' || field.type === 'list-item') insertBrAtCursor(e, block, field) }}
+ on:focus={() => setActiveBlock(block.id)}
+ />
+ {/if}
+ {#if (field.type === 'text' || field.type === 'list-item') && previewOpen[key]}
+
+
+ {@html getFieldValue(block, field)}
+
+
+ {/if}
+ {#if (field.type === 'text' || field.type === 'list-item') && getFieldValue(block, field).includes('\n')}
+
+
Переносы строк:
+
+ {@html visualizeLineBreaks(getFieldValue(block, field))}
+
+
+ {/if}
+ {#if typografStatus[key]?.error}
+
{typografStatus[key]?.error}
+ {/if}
+
+ {/each}
+
+
+
+ {#if pugOpen[block.id]}
+
+ {/if}
+ {/if}
+
+ {/each}
+ {#if !certEditingMode}
+
+ {#each quickBlocksView as quickName, i}
+ addQuickBlockByName(quickName)}
+ on:dragstart|stopPropagation={() => qbDragStart(i)}
+ on:dragover|preventDefault|stopPropagation={() => qbDragOver(i)}
+ on:dragend={() => qbDragEnd()}
+ title={quickName}
+ >
+ {@html getQuickBlockIcon(quickName)}
+
+ {/each}
+ { blockDropdownOpen = !blockDropdownOpen; blockDropdownSearch = '' }}>+ Добавить блок
+
+ {/if}
+
+ {/if}
+
+
+
+
+ {#if linkCheckActive && linkCheckResults.length}
+
+ {#each linkCheckResults as result}
+
+ {/each}
+
+ {/if}
+ {#if outputPanelMode === 'pug'}
+
+ {:else if outputPanelMode === 'html'}
+
+ {:else}
+
+ {#if previewError}
+
{previewError}
+ {/if}
+ {#if unavailableProducts.length > 0}
+
+
+ {#if !unavailableCollapsed}
+ {#each unavailableProducts as prod}
+
+ {prod.name || ''} ID {prod.id}{#if prod.price} · {Number(prod.price).toLocaleString('ru-RU')} руб.{/if}
+ loadSuggestions(prod.id)}>Замена
+
+ {/each}
+ {/if}
+
+ {#if suggestForProduct}
+
+
+
+ e.key === 'Enter' && loadSuggestions(suggestForProduct, suggestSearch)}
+ />
+ loadSuggestions(suggestForProduct, suggestSearch)}>Найти
+
+ {#if suggestLoading}
+
Загрузка...
+ {:else if suggestResults.length === 0}
+
Подходящих замен не найдено
+ {:else}
+ {#each suggestResults as s}
+
applySuggestion(suggestForProduct, s.id)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && applySuggestion(suggestForProduct, s.id)}
+ on:mouseleave={() => suggestHoverItem = null}>
+ {#if s.image}
{ suggestHoverItem = s; suggestHoverPos = { x: e.clientX, y: e.clientY } }}
+ on:mousemove={(e) => { suggestHoverPos = { x: e.clientX, y: e.clientY } }}
+ />{/if}
+
+
{s.name}
+
{s.price ? `${Number(s.price).toLocaleString('ru-RU')} руб.` : ''}
+
+
ID {s.id}
+
+ {/each}
+ {/if}
+
+ {/if}
+ {/if}
+ {#if previewStale && previewHtml}
+
Изменения в PUG не сгенерированы, нажми 🔄.
+ {/if}
+ {#if previewHtml}
+
+
+ { try { localStorage.setItem('va-previewZoom', previewZoom) } catch {} fitPreviewFrame(); if (currentUser) apiSavePreferences({ previewZoom }).catch(() => {}) }}
+ title={`Масштаб ${previewZoom}%`} />
+ {previewZoom}%
+
+
+
+
+
+ {:else if !previewLoading && !previewError}
+
Нажми 🔄, чтобы собрать и увидеть письмо.
+ {/if}
+
+ {/if}
+
+
+ {:else if activePage === 'settings'}
+
+
+
Настройки
+
Проект, блоки и интеграции
+
+
+
+
+
+ (settingsTab = 'project')}>
+ Проект
+
+ (settingsTab = 'blocks')}>
+ Блоки
+
+ (settingsTab = 'templates')}>
+ Шаблоны
+
+ { settingsTab = 'parts' }}>
+ Хедер / Футер
+
+ (settingsTab = 'integrations')}>
+ Интеграции
+
+ (settingsTab = 'certificate')}>
+ Сертификат
+
+ {#if currentUser?.role === 'admin'}
+ { settingsTab = 'users'; loadAdminUsers() }}>
+ Пользователи
+
+ {/if}
+
+
+
+
+
+ {#if settingsTab === 'project'}
+
+
+
+
Текущий проект
+
Настрой отображение и цвета для активного проекта.
+
+ Логотип проекта
+
+
+ Загрузить логотип
+
+
+ {#if settings.logoData}
+ Удалить
+ {/if}
+
+ PNG, JPG или WEBP. Логотип хранится в настройках проекта.
+
+
+ Название проекта в интерфейсе
+ updateProjectTitle(event.target.value)}
+ />
+
+
+ Папка проекта в email-gen
+ updateEmailGenProject(event.target.value)}
+ />
+ Используется для генерации HTML-превью из email-gen.
+
+
+ Цвет акцентов
+
+ updateAccentColor(event.target.value)}
+ />
+ updateAccentColor(event.target.value)}
+ />
+
+
+
Стиль гиперссылок
+
Шаблон для кнопки «A» в текстовых полях. Используй {`{url}`} и {`{text}`} как плейсхолдеры.
+
+
+
+
Пусто = стиль по умолчанию. Пример:
{`{text} `}
+
+
+
Источник блоков
+
Загрузи файл block.pug для текущего проекта.
+
+
+ Загрузить block.pug
+
+
+ loadFromUrl(DEFAULT_SOURCE, 'Block.pug')}>
+ Перезагрузить
+
+
+
Текущий файл: {sourceName}
+
+
+
+
+
Отступы
+
Глобальный отступ применяется ко всем блокам, если не задано иначе.
+
+ Глобальный отступ после блока
+ updateSettingsGlobalSpacing(event.target.value)}
+ />
+
+
+ Отступы не добавляются автоматически после блоков «Отступ 20» и «Отступ 40».
+
+
+
+
Быстрые блоки
+
Настрой список кнопок быстрого добавления в конструкторе.
+
+ (quickBlockCandidate = event.target.value)}
+ >
+ Выбери блок
+ {#each allBlocks as block}
+ {#if !block.isCustom}
+ {block.name}
+ {/if}
+ {/each}
+ {#if allBlocks.some(b => b.isCustom)}
+
+ {#each allBlocks as block}
+ {#if block.isCustom}
+ {block.name}
+ {/if}
+ {/each}
+
+ {/if}
+
+ addQuickBlockFromSettings(quickBlocksSelectRef?.value)}
+ >
+ Добавить
+
+
+ {#if quickBlocksError}
+
{quickBlocksError}
+ {:else if quickBlocksStatus}
+
{quickBlocksStatus}
+ {/if}
+
+ {#each quickBlocksView as quickName, i}
+ qbDragStart(i)}
+ on:dragover|preventDefault|stopPropagation={() => qbDragOver(i)}
+ on:dragend={() => qbDragEnd()}
+ >
+ ⠿
+ {quickName}
+ {#each QB_COLORS as c}
+ setQuickBlockColor(quickName, c.value)}
+ >
+ {/each}
+ removeQuickBlockFromSettings(quickName)}
+ aria-label="Удалить быстрый блок"
+ >
+ ×
+
+
+ {/each}
+
+
+
+
+
+
Аккаунт
+
{currentUser?.name || currentUser?.login} ({currentUser?.role})
+
Выйти из аккаунта
+
+ {:else if settingsTab === 'blocks'}
+
+
Поля блока
+
Выбери блок и настрой, какие поля показывать и как их подписывать.
+
+ Блок
+
+ {#each allBlocks as block, index}
+ {#if !block.isCustom}
+ {block.name}
+ {/if}
+ {/each}
+ {#if allBlocks.some(b => b.isCustom)}
+
+ {#each allBlocks as block, index}
+ {#if block.isCustom}
+ {block.name}
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
Опции товаров
+
Добавь опции, которые будут доступны в товарных блоках и в шапке.
+
+
+
+
+
Правила mixin-полей
+
Задай, какие аргументы mixin редактируются в конструкторе.
+
+
+
+
+
Пользовательские блоки
+
Создай свои блоки из Pug-кода. Поля определяются автоматически.
+ {#each settings.customBlocks || [] as cb (cb.id)}
+
+
+ {#if editingCustomBlockId === cb.id}
+
+ {@const previewSchema = buildBaseSchema(cb.content, cb.name, settings?.mixinRules)}
+ {#if previewSchema.length > 0}
+
+ {:else}
+
Поля не найдены
+ {/if}
+ {/if}
+
+ {/each}
+
+
+
+ {#if customBlockError}
+
{customBlockError}
+ {/if}
+
Добавить блок
+
+
+ {:else if settingsTab === 'templates'}
+
+
+ toggleAllBlocksSpacing(e.target.checked)}
+ />
+ Отступ после всех блоков
+
+
+
Импорт .pug
+
Скачать .pug
+
Сохранить .pug
+
+ {#if allBlocks.length > 8}
+
+ {/if}
+
+ {#each filteredTemplateBlocks as block, index}
+
+ {/each}
+ {#if filteredTemplateBlocks.length === 0}
+
Ничего не найдено
+ {/if}
+
+ {:else if settingsTab === 'parts'}
+
+
Редактор хедера / футера
+
Редактируй тексты кнопок и ссылки в pug-файлах хедера и футера.
+
Файл
+ loadPartsEditorFile(partsEditorFile)}>
+ — выбери файл —
+ {#each [
+ { label: 'Хедер (Ж)', val: genderHeaderFemale },
+ { label: 'Хедер (М)', val: genderHeaderMale },
+ { label: 'Футер (Ж)', val: genderFooterFemale },
+ { label: 'Футер (М)', val: genderFooterMale },
+ ].filter(o => o.val) as opt}
+ {opt.label} — {opt.val}
+ {/each}
+
+
+ {#if partsEditorLoading}
+
Загрузка...
+ {:else if partsEditorFields.length > 0}
+
+
+
+ {partsEditorSaving ? 'Сохранение...' : 'Сохранить'}
+
+ {#if partsEditorSuccess}{partsEditorSuccess} {/if}
+ {#if partsEditorError}{partsEditorError} {/if}
+
+ {:else if partsEditorFile}
+
Редактируемых полей не найдено.
+ {/if}
+
+ {:else if settingsTab === 'integrations'}
+
+
Yonote API
+
+ API-токен из Yonote (Настройки → API). Используется для чтения и записи в базу данных Yonote.
+
+
+ API токен
+
+
+
+ Base URL (необязательно)
+
+
+ {#if yonoteConfigError}
{yonoteConfigError}
{/if}
+
+ {yonoteSaving ? 'Сохраняю…' : 'Сохранить'}
+ {#if yonoteStatus.configured}
+ {yonoteStatus.connected ? 'Подключён' : 'Не подключён'}
+ {/if}
+ {#if yonoteConfigStatus}{yonoteConfigStatus} {/if}
+
+
+
+
База данных Yonote для «{currentProject}»
+
Выбери базу данных и сопоставь свойства для страницы «План».
+
+ База данных
+
+ loadYonoteDbProperties(yonoteCfgDatabaseId)}>
+ — Выбери базу —
+ {#each yonoteDatabases as db}
+ {db.title}
+ {/each}
+ {#if yonoteCfgDatabaseId && !yonoteDatabases.find(d => d.id === yonoteCfgDatabaseId)}
+ {yonoteCfgDatabaseId}
+ {/if}
+
+ {yonoteDbLoading ? 'Загружаю…' : 'Обновить список'}
+
+
+ {#if yonoteDbProperties.length > 0 || yonotePropsLoading}
+
Сопоставь ключевые поля. Все остальные свойства из Yonote будут показаны автоматически.
+
+ Дата
+
+ — не выбрано —
+ {#each yonoteDbProperties as p}{p.title} ({p.type}) {/each}
+
+
+ Тема (заголовок)
+
+ — не выбрано —
+ {#each yonoteDbProperties as p}{p.title} ({p.type}) {/each}
+
+
+ Прехедер
+
+ — не выбрано —
+ {#each yonoteDbProperties as p}{p.title} ({p.type}) {/each}
+
+
+ Статус
+
+ — не выбрано —
+ {#each yonoteDbProperties as p}{p.title} ({p.type}) {/each}
+
+
+
+ {#if yonoteStatusOptions.length > 0}
+
Статус кнопки «Отправлено»
+
+ — не выбрано —
+ {#each yonoteStatusOptions as s}{s} {/each}
+
+ Какой статус ставить при нажатии на кнопку в конструкторе
+
+ {/if}
+
+ Свойства базы: {yonoteDbProperties.map(p => p.title).join(', ')}
+
+ {/if}
+
+ Сохранить
+ {yonoteTestLoading ? 'Проверяю…' : 'Проверить подключение'}
+
+ {#if yonoteTestResult}
+ {@const isOk = yonoteTestResult.startsWith('ok:')}
+
{yonoteTestResult.replace(/^(ok|error):/, '')}
+ {/if}
+
+
+
FTP/SFTP загрузка для «{currentProject}»
+
Загружайте изображения на FTP/SFTP сервер. Папка по дате письма создаётся автоматически.
+
+ Протокол
+
+ FTP
+ SFTP
+
+
+
+
+ Хост
+
+
+
+ Порт
+
+
+
+
+
+ Логин
+
+
+
+ Пароль
+
+
+
+
+ Удалённый путь (базовая папка на сервере)
+
+
+
+ Публичный URL (без / в конце)
+
+
+
Результат: {ftpBaseUrl || 'https://...'}/{getLetterDateFolder() || 'DD-MM'}/имя.png
+
+ Сохранить
+
+ {ftpTestLoading ? 'Проверяю…' : 'Проверить'}
+
+
+ {#if ftpTestResult}
+ {@const isOk = ftpTestResult.startsWith('ok:')}
+
+ {ftpTestResult.replace(/^(ok|error):/, '')}
+
+ {/if}
+
+
+
Сервис рассылок
+
Кнопка под карточкой сборки для перехода в сервис
+
Текст кнопки
+
+
+
Ссылка
+
+
+
+ Сохранить
+
+
+
+
+
Гендерные пути
+
Include-пути для header/footer по гендеру. Используются при переключении Ж/М.
+
+ Header (Ж)
+
+ — не выбрано —
+ {#each partsFiles.filter(f => f.includes('header')) as f}{f} {/each}
+
+
+ Header (М)
+
+ — не выбрано —
+ {#each partsFiles.filter(f => f.includes('header')) as f}{f} {/each}
+
+
+
+
+ Footer (Ж)
+
+ — не выбрано —
+ {#each partsFiles.filter(f => f.includes('footer')) as f}{f} {/each}
+
+
+ Footer (М)
+
+ — не выбрано —
+ {#each partsFiles.filter(f => f.includes('footer')) as f}{f} {/each}
+
+
+
+
+ Сохранить
+
+
+
+
+
Автонумерация картинок
+
Базовый URL и расширение для автоматической нумерации картинок в блоках.
+
+ Базовый URL
+
+
+ Расширение
+
+
+
+
+ Сохранить
+
+
+
+
+
Фид товаров
+
URL YML-фида каталога. Товары подставятся в превью вместо Mindbox-тегов.
+
URL фида
+
+
+
+ Сохранить
+ {#if feedUrlDraft.trim()}
+
+ {feedRefreshLoading ? '⏳ Загрузка фида…' : '🔄 Обновить фид'}
+
+ {/if}
+
+ {#if feedRefreshResult}
+ {@const isOk = feedRefreshResult.startsWith('ok:')}
+
+ {feedRefreshResult.replace(/^(ok|error):/, '')}
+
+ {/if}
+ {#if feedRefreshDiff?.addedProducts?.length}
+
+
Новые товары:
+ {#each feedRefreshDiff.addedProducts as p}
+
+ {p.id}
+ {p.name}
+
+ {/each}
+ {#if feedRefreshDiff.added > feedRefreshDiff.addedProducts.length}
+
…и ещё {feedRefreshDiff.added - feedRefreshDiff.addedProducts.length}
+ {/if}
+
+ {/if}
+
+ {:else if settingsTab === 'users' && currentUser?.role === 'admin'}
+
+ {#if adminLoading}
+
Загрузка...
+ {:else}
+ {#if adminError}
{adminError}
{/if}
+
+
+
+ Логин
+ Имя
+ Роль
+ Проекты
+
+
+
+
+ {#each adminUsers as user}
+
+ {user.login}
+ {user.name}
+
+ adminUpdateUser(user.id, { role: e.target.value })}>
+ admin
+ user
+
+
+
+ {PROJECT_NAME}
+
+
+ {#if user.id !== currentUser.id}
+ adminDeleteUser(user.id)} title="Удалить">×
+ {/if}
+
+
+ {/each}
+
+
+
+ {/if}
+
+ {:else if settingsTab === 'certificate'}
+
+
Сертификат
+
Группа блоков, которая добавляется в конец письма. Включается/выключается в конструкторе для каждого письма отдельно.
+
+
+
+ {certBlocks.length > 0 ? 'Редактировать' : 'Создать'}
+
+ {#if certBlocks.length > 0}
+ {certBlocks.length} блок(а/ов)
+ saveCertificate([])}>Очистить
+ {/if}
+
+ {#if certBlocks.length > 0}
+
+ {#each certBlocks as block}
+ {block.name}
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+
+ {:else if activePage === 'plan'}
+
+
+
План
+
Ближайшие рассылки из всех проектов
+
+
+ {#if planLoadedAt}Обновлено {planLoadedAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} {/if}
+ planShowDone = !planShowDone}>
+ {planShowDone ? 'Скрыть выполненные' : 'Показать выполненные'}
+
+ loadPlan()} disabled={planLoading}>{planLoading ? 'Загрузка…' : 'Обновить'}
+
+
+
+
+ {#if planLoading && planRows.length === 0}
+
Загружаю данные из таблиц…
+ {:else if planError}
+
{planError}
+ {:else if planRows.length === 0}
+
Нет рассылок в диапазоне −7 / +60 дней, либо таблицы не настроены (Настройки → Интеграции).
+ {#if planWarnings.length > 0}
+
{#each planWarnings as w}
⚠ {w}
{/each}
+ {/if}
+ {:else}
+ {#if planWarnings.length > 0}
+
{#each planWarnings as w}
⚠ {w}
{/each}
+ {/if}
+
+ {#each planPeriodGroups as period}
+
+
{period.label}
+
+ {#each period.groups as dayGroup}
+
+
+ {#each dayGroup.rows as row}
+
+
{row.project}
+
+
+
{row.subject}
+
+ {#if planEditingRowId === planRowId(row)}
+ setPlanRowStatus(row, e.target.value)}
+ on:blur={() => { if (planEditingRowId === planRowId(row)) planEditingRowId = null }}
+ >
+ {#each planStatusOptions as opt}
+ {opt}
+ {/each}
+ {#if !planStatusOptions.includes(row.status)}
+ {row.status}
+ {/if}
+
+ {#if planSavingRowId === planRowId(row)}сохранение… {/if}
+ {:else}
+ {#if row.status}
+ planEditingRowId = planRowId(row)}
+ >{row.status}
+ {:else}
+ planEditingRowId = planRowId(row)}>+ статус
+ {/if}
+ {/if}
+ {#if row.preheader && row.status}· {/if}
+ {#if row.preheader}{/if}
+ {#each (row.extra || []).filter(e => e.link || isUrl(e.value)) as ext}
+
+ {/each}
+
+ {#if (row.extra || []).some(e => !e.link && !isUrl(e.value))}
+
+ {/if}
+
+
startAssemble(row)}>Собирать →
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+ ‹
+ {planCalMonth.toLocaleString('ru-RU', { month: 'long', year: 'numeric' })}
+ ›
+
+
+ {#each ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] as dow}
+
{dow}
+ {/each}
+ {#each planCalDays as day}
+
0}
+ on:click={() => selectPlanDate(day.isoStr)}
+ >
+
{day.date.getDate()}
+ {#if day.rows.length > 0}
+
+ {#each day.rows.slice(0, 4) as row}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+
+
+ {:else if activePage === 'stats'}
+
+
+
Статистика
+
Время работы над письмами
+
+
+
+ {#if statsPageLoading}
+
Загрузка...
+ {:else if Object.keys(allStats).length === 0}
+
Нет данных
+ {:else}
+ {#each statsMonthGroups as [monthKey, entries]}
+ {@const [y, m] = monthKey.split('-')}
+ {@const monthLabel = new Date(Number(y), Number(m) - 1).toLocaleString('ru-RU', { month: 'long', year: 'numeric' })}
+ {@const totalMin = entries.reduce((s, e) => s + (e.minutes || 0), 0)}
+
+
+
+
+
+ Проект
+ Письмо
+ Тег
+ Время
+ Статус
+
+
+
+ {#each entries.sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || '')) as entry}
+
+ {entry.project}
+ {entry.letterName}
+ {entry.tag || ''}
+ {entry.minutes || 0} мин
+ {entry.status || ''}
+
+ {/each}
+
+
+
+ {/each}
+ {/if}
+
+ {:else}
+
+
+
Письма
+
Сохранённые письма
+
+
+
+
+ {#if presets.length > 0}
+
+
Пресеты
+
+ {#each presets as preset}
+
+ applyPresetBlocks(preset)}>
+ {preset.name}
+
+ deletePresetById(preset.id)}
+ title="Удалить пресет"
+ aria-label="Удалить пресет"
+ >
+ ×
+
+
+ {/each}
+
+
+ {/if}
+ {#if letters.length > 8}
+
+ {/if}
+ {#if filteredLetters.length === 0}
+
{letterSearch ? 'Ничего не найдено.' : 'Писем пока нет.'}
+ {:else}
+
+ {#each filteredLetters as letter}
+
+
+
{letter.name}
+
{formatPresetDate(letter.updatedAt || letter.createdAt)}
+
+
+ duplicateLetter(letter)}>Копировать
+ openLetter(letter)}>Открыть
+ openHistory(letter)}>История
+ deleteLetter(letter)}>×
+
+ {#if showHistoryFor?.id === letter.id}
+
+ {#if historyLoading}
+
Загрузка…
+ {:else if historySnapshots.length === 0}
+
История пуста
+ {:else}
+ {#each historySnapshots as snap}
+
restoreFromHistory(snap)}>
+ {new Date(snap.savedAt).toLocaleString('ru')}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+ {#if showLetterModal}
+
(showLetterModal = false)}>
+
+
+ {letterModalMode === 'update' ? 'Сохранить письмо' : 'Новое письмо'}
+
+
Шаг 1. Дата отправки
+
+
Шаг 2. Название письма
+
+
+ Итоговое имя: {buildLetterName(newLetterTitle, newLetterDate)}
+
+ {#if newLetterError}
+
{newLetterError}
+ {/if}
+
+ (showLetterModal = false)}>Отмена
+
+ {letterModalMode === 'update' ? 'Сохранить' : 'Создать письмо'}
+
+
+
+
+ {/if}
+
+ {#if showPresetModal}
+
(showPresetModal = false)}>
+
+
Новый пресет
+
Короткое имя пресета
+
event.key === 'Enter' && createPresetFromLetter()}
+ />
+ {#if presetError}
+
{presetError}
+ {/if}
+
+ (showPresetModal = false)}>Отмена
+ Создать
+
+
+
+ {/if}
+
+ {#if showStartupModal}
+
+
+
{currentProject}
+ {#if startupLastLetter}
+
Последнее письмо
+
{ showStartupModal = false; openLetter(startupLastLetter) }}
+ >
+ {startupLastLetter.name}
+
+
или
+ {/if}
+
{ showStartupModal = false; openLetterModal('new') }}>
+ Создать новое письмо
+
+ {#if startupPlanRows.length > 0}
+
рассылки из плана
+
+ {#each startupPlanRows as row}
+ {@const isPast = row.date && row.date < new Date(new Date().setHours(0,0,0,0))}
+ {@const isDone = planStatusClass(row.status) === 'done'}
+ {#if !isPast || !isDone}
+ {@const overdue = isPast && !isDone}
+ { showStartupModal = false; startAssemble(row) }}
+ >
+ {row.dateStr}
+ {row.subject}
+ {#if row.status}{row.status} {/if}
+
+ {/if}
+ {/each}
+
+ {/if}
+
+ (showStartupModal = false)}>
+ Продолжить без письма
+
+
+
+
+ {/if}
+
+ {#if showFtpGalleryModal}
+
(showFtpGalleryModal = false)}>
+
+
+ FTP: {settings.ftpConfig?.host}
+ / {ftpGalleryFolder}
+
+
{settings.ftpConfig?.remotePath}/{ftpGalleryFolder}/
+
+ {#if ftpGalleryLoading && ftpGalleryImages.length === 0}
+
Загружаю список…
+ {:else if ftpGalleryError}
+
{ftpGalleryError}
+ {:else if ftpGalleryImages.length === 0}
+
Папка пуста
+ {:else}
+
+ {#each ftpGalleryImages as img}
+
+
{ if (ftpDeletePending === img) return; selectFtpImage(img.url) }}>
+
{ e.target.style.display = 'none' }} />
+
+
+
+ {/each}
+
+ {/if}
+
+
+ {ftpGalleryLoading && ftpGalleryImages.length > 0 ? 'Обновляю…' : 'Обновить'}
+
+ (showFtpGalleryModal = false)}>Закрыть
+
+
+
+ {/if}
+
+ {#if showNoteModal}
+
(showNoteModal = false)}>
+
+
Новая заметка
+
Название заметки
+
event.key === 'Enter' && createNote()}
+ />
+ {#if noteError}
+
{noteError}
+ {/if}
+
+ (showNoteModal = false)}>Отмена
+ Создать
+
+
+
+ {/if}
+{#if quickEditVisible && quickEditBlock && quickEditField}
+
+{/if}
+
+
+{#if toasts.length > 0}
+
+ {#each toasts as toast (toast.id)}
+
+
{toast.message}
+
toasts = toasts.filter(t => t.id !== toast.id)}>×
+
+ {/each}
+
+{/if}
+
+{#if suggestHoverItem}
+
+{/if}
+
+{/if}
diff --git a/z51-pug-builder/src/app.css b/z51-pug-builder/src/app.css
new file mode 100644
index 0000000..373e0c9
--- /dev/null
+++ b/z51-pug-builder/src/app.css
@@ -0,0 +1,3494 @@
+* { box-sizing: border-box; }
+html, body, #app { margin: 0; padding: 0; height: 100%; }
+
+/* --- Login page --- */
+.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: var(--bg) center/cover no-repeat; background-color: #1a1a2e; }
+.login-page[style*="background-image"]::before { content: ''; position: absolute; inset: 0; background: rgba(0,0,0,0.45); }
+.login-page { position: relative; }
+.login-loading { color: var(--muted); font-size: 14px; position: relative; z-index: 1; }
+.login-card { display: flex; flex-direction: column; gap: 12px; width: 320px; padding: 32px; background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius); position: relative; z-index: 1; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
+.login-title { font-size: 22px; font-weight: 700; text-align: center; letter-spacing: 0.05em; margin-bottom: 8px; color: var(--text); }
+.login-btn { width: 100%; justify-content: center; font-weight: 600; background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
+.login-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.login-btn:disabled { opacity: 0.5; }
+
+/* --- Header user --- */
+.header-user { font-size: 12px; color: var(--muted); white-space: nowrap; padding: 0 4px; }
+
+/* --- Admin page --- */
+.admin-page { padding: 0 16px 24px; }
+.admin-projects-cell { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
+.admin-project-tag { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; background: var(--pill); padding: 2px 6px; border-radius: 4px; }
+.admin-tag-remove { background: none; border: none; cursor: pointer; color: var(--muted); font-size: 12px; padding: 0 2px; line-height: 1; }
+.admin-tag-remove:hover { color: var(--error); }
+.admin-all-projects { font-size: 11px; color: var(--success); font-weight: 600; }
+.admin-add-project select { font-size: 11px; padding: 1px 4px; border: 1px dashed var(--panel-border); background: transparent; color: var(--text); cursor: pointer; border-radius: 4px; }
+.admin-new-user { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--line); }
+.admin-new-title { font-size: 14px; font-weight: 600; margin-bottom: 10px; }
+.admin-new-fields { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.admin-new-fields .field-control { min-width: 120px; flex: 1; }
+.admin-new-fields select.field-control { min-width: 80px; flex: 0; }
+
+:root {
+ --bg: #ffffff;
+ --bg-alt: #f4f4f6;
+ --panel: #ffffff;
+ --panel-border: #A5A8BE80;
+ --panel-shadow: 0 2px 30px rgba(33, 49, 73, 0.08);
+ --text: #130F33;
+ --muted: #7D7882;
+ --line: #A5A8BE80;
+ --accent: #130F33;
+ --accent-hover: #162668;
+ --accent-contrast: #ffffff;
+ --accent-secondary: #AAC8C8;
+ --pill: #f4f4f6;
+ --pill-border: #A5A8BE80;
+ --input-bg: transparent;
+ --input-border: #7D7882;
+ --card: #ffffff;
+ --code-bg: #f4f4f6;
+ --code-border: #A5A8BE80;
+ --drag: rgba(19, 15, 51, 0.08);
+ --success: #10b981;
+ --error: #D92D20;
+ --radius: 0px;
+}
+
+body.theme-dark {
+ --bg: #130F33;
+ --bg-alt: #1a1545;
+ --panel: #1a1545;
+ --panel-border: rgba(255, 255, 255, 0.1);
+ --panel-shadow: 0 2px 30px rgba(0, 0, 0, 0.3);
+ --text: #e4e7ec;
+ --muted: #ADB2BD;
+ --line: rgba(255, 255, 255, 0.1);
+ --accent: #AAC8C8;
+ --accent-hover: #c0d8d8;
+ --accent-contrast: #130F33;
+ --accent-secondary: #AAC8C8;
+ --pill: #221c52;
+ --pill-border: rgba(255, 255, 255, 0.1);
+ --input-bg: transparent;
+ --input-border: rgba(255, 255, 255, 0.2);
+ --card: #1a1545;
+ --code-bg: #0f0b2a;
+ --code-border: rgba(255, 255, 255, 0.08);
+ --drag: rgba(170, 200, 200, 0.12);
+ --success: #10b981;
+ --error: #D92D20;
+ --radius: 0px;
+}
+
+/* Reaspekt dark theme: mint subtitles */
+body.theme-dark .panel-subtitle,
+body.theme-dark .code-panel-subtitle { color: var(--accent-secondary); }
+body.theme-dark .settings-section-title { color: var(--accent-secondary); }
+
+body {
+ font-family: "Onest", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ line-height: 1.55;
+ background: var(--bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.page {
+ min-height: 100vh;
+ background: var(--bg);
+ display: grid;
+ grid-template-columns: auto 1fr;
+}
+
+.workspace {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 0 20px 32px;
+ gap: 20px;
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+.workspace-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 14px 20px 12px;
+ position: sticky;
+ top: 0;
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--line);
+ z-index: 10;
+ width: calc(100% + 40px);
+ margin-left: -20px;
+}
+.workspace-header::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 120px;
+ height: 100%;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ -45deg,
+ transparent,
+ transparent 4px,
+ color-mix(in srgb, var(--accent-secondary) 8%, transparent) 4px,
+ color-mix(in srgb, var(--accent-secondary) 8%, transparent) 5px
+ );
+ mask-image: linear-gradient(to left, rgba(0,0,0,0.5), transparent);
+ -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.5), transparent);
+}
+.workspace-header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+.letter-title {
+ font-size: 14px;
+ color: var(--muted);
+ margin-left: 8px;
+ white-space: nowrap;
+ cursor: text;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.letter-title-input {
+ font-size: 14px;
+ color: var(--text);
+ margin-left: 8px;
+ white-space: nowrap;
+ background: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ padding: 0;
+ border-radius: 0;
+ appearance: none;
+ -webkit-appearance: none;
+ line-height: 1.2;
+}
+.letter-date {
+ cursor: text;
+}
+.letter-name {
+ cursor: text;
+}
+.letter-gap {
+ display: inline-block;
+ width: 4px;
+}
+.letter-date-input {
+ font-size: 14px;
+ color: var(--text);
+ background: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ padding: 0;
+ border-radius: 0;
+ appearance: none;
+ -webkit-appearance: none;
+ line-height: 1.2;
+}
+.preset-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.preset-inline .field-control {
+ min-width: 220px;
+}
+.product-options-bar {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: 8px;
+ flex-wrap: wrap;
+}
+.product-options-inline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 6px 0 8px;
+}
+.product-options-settings {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.product-option-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr auto auto;
+ gap: 6px;
+ align-items: center;
+ padding: 6px 10px;
+ background: var(--bg-alt);
+ border-radius: var(--radius);
+}
+.product-option-row .field-control { min-width: 0; }
+.parts-editor-fields { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
+.parts-editor-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; }
+.parts-editor-row.single { grid-template-columns: 1fr; }
+.parts-editor-group-title {
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
+ color: var(--muted); margin: 10px 0 4px; padding-bottom: 4px; border-bottom: 1px solid var(--line);
+}
+.parts-editor-group-title:first-child { margin-top: 0; }
+.parts-editor-type { font-size: 13px; text-align: center; color: var(--muted); }
+.product-option-default {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+}
+.preset-links {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+.preset-page-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 8px;
+}
+.letters-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px;
+}
+.preset-quick-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 6px 0 10px;
+}
+.preset-pill-btn {
+ background: var(--pill);
+ border: 1px solid var(--pill-border);
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 500;
+ padding: 4px 12px;
+ min-height: 28px;
+ border-radius: var(--radius);
+ box-shadow: none;
+}
+.preset-pill-btn:hover {
+ background: var(--bg-alt);
+ border-color: var(--muted);
+ box-shadow: none;
+}
+.preset-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.panel-subsection {
+ margin-bottom: 12px;
+}
+.letter-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ border: none;
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ background: var(--panel);
+ box-shadow: var(--panel-shadow);
+ gap: 0;
+}
+.letter-row-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.letter-row-name {
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 60vw;
+}
+.letter-row-date {
+ color: var(--muted);
+ white-space: nowrap;
+ font-size: 12px;
+}
+.letter-row-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+.letter-row:hover .letter-row-actions,
+.letter-row.history-open .letter-row-actions {
+ opacity: 1;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+body.theme-dark .modal-backdrop {
+ background: rgba(0, 0, 0, 0.5);
+}
+.modal-card {
+ background: var(--panel);
+ border: none;
+ border-radius: 14px;
+ padding: 24px;
+ width: 420px;
+ max-width: calc(100% - 32px);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
+}
+.modal-title {
+ font-size: 16px;
+ font-weight: 600;
+}
+.modal-step {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 4px;
+}
+.modal-preview {
+ font-size: 12px;
+ color: var(--muted);
+}
+.modal-error {
+ font-size: 12px;
+ color: var(--danger);
+}
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 4px;
+}
+.preset-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px;
+}
+.preset-toolbar .field-control {
+ min-width: 220px;
+}
+.preset-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ background: var(--panel);
+ border: none;
+ border-radius: var(--radius);
+ padding: 14px;
+ box-shadow: var(--panel-shadow);
+}
+.preset-card-main {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.preset-name {
+ font-size: 16px;
+ font-weight: 600;
+}
+.preset-date {
+ font-size: 12px;
+ color: var(--muted);
+}
+.preset-links .text-link {
+ background: transparent;
+ border: none;
+ color: var(--text);
+ font-weight: 600;
+ font-size: 13px;
+ cursor: pointer;
+ padding: 4px 4px;
+ border-radius: var(--radius);
+}
+.preset-links .text-link:hover {
+ color: var(--accent);
+ background: rgba(255, 255, 255, 0.04);
+}
+.preset-links .text-link:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 2px;
+}
+.preset-links-sidebar {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ margin: 2px 0 6px;
+}
+
+.workspace-meta-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.workspace-breadcrumb {
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.workspace-breadcrumb .sep { opacity: 0.6; }
+
+.ghost-link {
+ font-size: 12px;
+ color: var(--muted);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--line);
+ padding-bottom: 1px;
+}
+.ghost-link:hover {
+ color: var(--text);
+ border-color: var(--text);
+}
+
+.btn-textual {
+ width: auto;
+ min-width: unset;
+ padding: 6px 12px;
+ font-weight: 600;
+}
+.btn-textual span {
+ font-size: 12px;
+ letter-spacing: 0.03em;
+}
+.btn-textual.danger {
+ border-color: #ba473a;
+ color: #ba473a;
+ background: transparent;
+}
+
+.workspace-header-tools {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-left: auto;
+ flex: 0 0 auto;
+ justify-content: flex-end;
+}
+.header-status {
+ font-size: 12px;
+ color: var(--success);
+ white-space: nowrap;
+}
+.workspace-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ position: relative;
+ padding-left: 14px;
+}
+.workspace-header-actions::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 50%;
+ width: 1px;
+ height: 24px;
+ background: var(--line);
+ transform: translateY(-50%);
+}
+.image-base-field {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+}
+.image-base-input {
+ width: 100%;
+ min-width: 260px;
+ max-width: none;
+ height: auto;
+ padding: 4px 2px;
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ font-size: 13px;
+}
+.image-base-input:focus {
+ outline: none;
+ border: none;
+ box-shadow: none;
+}
+.workspace-header .pug-actions {
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ gap: 3px;
+}
+
+/* Reaspekt header buttons: dark fill, white text; hover: mint fill, dark text */
+.workspace-header .workspace-meta-row .btn,
+.workspace-header .workspace-header-actions .btn:not(.theme-switch *) {
+ background: var(--accent);
+ color: var(--accent-contrast);
+ border-color: var(--accent);
+}
+.workspace-header .workspace-meta-row .btn .icon-img,
+.workspace-header .workspace-header-actions .btn:not(.theme-switch *) .icon-img {
+ filter: invert(1);
+ opacity: 1;
+}
+.workspace-header .workspace-meta-row .btn:hover,
+.workspace-header .workspace-header-actions .btn:not(.theme-switch *):hover {
+ background: var(--accent-secondary);
+ color: #130F33;
+ border-color: var(--accent-secondary);
+}
+.workspace-header .workspace-meta-row .btn:hover .icon-img,
+.workspace-header .workspace-header-actions .btn:not(.theme-switch *):hover .icon-img {
+ filter: none;
+ opacity: 0.8;
+}
+/* Keep toggled state distinct */
+.workspace-header-actions .btn[aria-pressed="true"],
+.workspace-header-actions .btn.toggled {
+ background: var(--accent-secondary);
+ border-color: var(--accent-secondary);
+ color: #130F33;
+ box-shadow: none;
+}
+.workspace-header-actions .btn.toggled .icon-img {
+ filter: none;
+ opacity: 0.8;
+}
+.workspace-header-actions .btn[aria-pressed="true"]:hover,
+.workspace-header-actions .btn.toggled:hover {
+ background: var(--accent-secondary);
+ border-color: var(--accent-secondary);
+ color: #130F33;
+ box-shadow: none;
+}
+
+.workspace-label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+
+.workspace-title {
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.workspace-subtitle {
+ font-size: 14px;
+ color: var(--muted);
+}
+
+.workspace-grid {
+ width: 100%;
+ display: grid;
+ grid-template-columns: minmax(560px, 1fr) minmax(360px, 1fr);
+ gap: 10px;
+ align-items: start;
+}
+.builder-grid {
+ height: calc(100vh - 160px);
+ grid-template-columns: minmax(400px, 1fr) minmax(380px, 620px);
+}
+.builder-grid.preview-focus {
+ grid-template-columns: minmax(400px, 1fr) minmax(380px, 620px);
+}
+.builder-grid .panel.panel-left {
+ min-width: 0;
+ height: 100%;
+ overflow: hidden;
+ max-width: none;
+}
+.builder-grid .panel.panel-right {
+ min-width: 0;
+ max-width: none;
+ width: 100%;
+}
+.builder-grid .blocks-container {
+ max-height: 100%;
+ padding-bottom: 50vh;
+}
+
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+.panel-title { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; padding-left: 12px; border-left: 3px solid var(--accent-secondary); }
+.panel-subtitle { font-size: 12px; color: var(--muted); letter-spacing: 0.12em; text-transform: uppercase; }
+
+.settings-section {
+ padding: 0 0 20px;
+ margin-bottom: 0;
+ border-bottom: 1px solid var(--panel-border);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.settings-section:last-child { border-bottom: none; padding-bottom: 0; }
+.panel > .settings-section:first-child { padding-top: 4px; }
+.panel .settings-section + .settings-section { padding-top: 20px; }
+.inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.settings-section > label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; font-weight: 500; color: var(--muted); }
+.settings-section > label .field-control { font-size: 13px; }
+.settings-section .inline label:not(.btn) { display: flex; flex-direction: column; gap: 3px; font-size: 11px; font-weight: 500; color: var(--muted); min-width: 0; flex-shrink: 0; }
+.settings-section .inline label:not(.btn) .field-control { min-width: 0; }
+.settings-section .inline label input[type="checkbox"] { margin: 0; }
+.settings-section .inline label input[type="file"] { display: none; }
+.settings-section-title {
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: -6px;
+ letter-spacing: -0.01em;
+ padding-left: 10px;
+ border-left: 3px solid var(--accent-secondary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.settings-section-title::before {
+ content: '↘';
+ color: var(--accent-secondary);
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1;
+}
+.settings-section-desc {
+ font-size: 12px;
+ color: var(--muted);
+ margin-bottom: 2px;
+ line-height: 1.5;
+}
+
+/* Settings page — card layout */
+.settings-grid {
+ display: block;
+}
+.settings-grid .panel-left {
+ max-width: none;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.settings-grid .settings-section {
+ background: var(--panel);
+ padding: 18px 22px 22px;
+ border-radius: var(--radius);
+ border: none;
+ box-shadow: var(--panel-shadow);
+}
+.settings-grid .settings-section:last-child {
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--panel-border);
+}
+.settings-grid .panel > .settings-section:first-child {
+ padding-top: 16px;
+}
+.settings-grid .panel .settings-section + .settings-section {
+ padding-top: 16px;
+}
+.settings-grid .settings-section > label > .field-control,
+.settings-grid .settings-section > label > select.field-control {
+ width: 100%;
+ min-width: 0;
+}
+.settings-grid .settings-section .inline .field-control {
+ min-width: 0;
+ flex: 1;
+}
+.settings-grid .settings-section .inline .field-control.inline-col {
+ flex: 0 0 48px;
+ width: 48px;
+ min-width: 48px;
+}
+/* Two-column layout for "Project" tab */
+.settings-two-col {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ align-items: start;
+}
+.settings-col {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.theme-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.theme-toggle-inner { display: flex; align-items: center; gap: 6px; }
+.theme-icon { display: none; }
+.theme-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
+.theme-switch input { opacity: 0; width: 0; height: 0; }
+.theme-slider {
+ position: absolute; inset: 0;
+ cursor: pointer;
+ background: var(--pill);
+ border-radius: 999px;
+ transition: 0.2s;
+ border: none;
+}
+.theme-slider::before {
+ content: "";
+ position: absolute;
+ height: 14px;
+ width: 14px;
+ left: 5px;
+ top: 5px;
+ background: #fefefe;
+ border-radius: 50%;
+ transition: 0.2s;
+ box-shadow: none;
+}
+.theme-slider::after {
+ content: none;
+}
+.theme-switch input:checked + .theme-slider { background: #3e444c; border-color: #3e444c; }
+.theme-switch input:checked + .theme-slider::before { transform: translateX(20px); background: #fefefe; }
+.theme-switch input:checked + .theme-slider::after {
+ right: 26px;
+ background: var(--accent-contrast);
+}
+
+.theme-slider::after {
+ content: none;
+}
+
+.panel-left { max-width: 780px; width: 100%; padding: 20px; gap: 0; }
+.panel-right { width: 100%; max-width: 460px; padding: 20px; gap: 0; }
+
+.id-manager-panel {
+ width: 100%;
+ margin-bottom: 14px;
+ padding: 10px 8px;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--panel);
+ box-shadow: var(--panel-shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.id-manager-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.id-manager-title { font-weight: 700; font-size: 14px; letter-spacing: 0.02em; }
+.id-manager-count { font-size: 12px; color: var(--muted); }
+
+.id-manager-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.id-manager-input {
+ width: 100%;
+ min-height: 60px;
+ padding: 6px 6px;
+ border-radius: var(--radius);
+ border: none;
+ background: transparent;
+ font-family: "JetBrains Mono", "SFMono-Regular", monospace;
+ font-size: 13px;
+ color: var(--text);
+ resize: vertical;
+}
+
+.id-manager-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 6px;
+ border-radius: var(--radius);
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ max-height: 110px;
+ overflow-y: auto;
+}
+
+.id-manager-preview .id-chip {
+ padding: 2px 6px;
+ border-radius: var(--radius);
+ background: var(--pill);
+ border: 1px solid var(--pill-border);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ color: var(--text);
+ outline: none;
+ min-width: 0;
+ max-width: none;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: max-content;
+ width: fit-content;
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+.id-manager-preview .id-chip:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+.id-manager-preview .id-chip.free {
+ background: color-mix(in srgb, var(--success) 8%, transparent);
+ border-color: color-mix(in srgb, var(--success) 35%, transparent);
+ color: var(--text);
+}
+.id-manager-preview .id-chip.used {
+ text-decoration: line-through;
+ color: var(--muted);
+ opacity: 0.6;
+ background: transparent;
+}
+
+.id-manager-hint {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.tabs { display: inline-flex; gap: 8px; margin-bottom: 0; }
+.tabs button {
+ padding: 4px 8px;
+ border: 1px solid var(--panel-border);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ border-radius: var(--radius);
+ transition: all 140ms ease;
+ font-weight: 600;
+ min-height: 26px;
+}
+.tabs button:hover { background: var(--bg-alt); color: var(--text); }
+.tabs button.tab-active { background: var(--text); color: var(--panel); border-color: var(--text); font-weight: 700; }
+.tabs-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.sidebar-tabs {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+.tabs-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--bg-alt);
+ border-radius: var(--radius);
+ padding: 3px;
+}
+.tabs-actions .btn {
+ border: none;
+ border-radius: var(--radius);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 6px 16px;
+ background: transparent;
+ color: var(--muted);
+ transition: all 0.15s;
+}
+.tabs-actions .btn:hover { color: var(--text); background: transparent; border-color: transparent; box-shadow: none; }
+.tabs-actions .btn.toggled {
+ background: var(--panel);
+ color: var(--text);
+ font-weight: 600;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+ outline: none;
+}
+.price-toggle {
+ min-width: 32px;
+ min-height: 30px;
+ padding: 0;
+ justify-content: center;
+ font-weight: 700;
+ border-radius: var(--radius);
+}
+.price-toggle[aria-pressed="true"] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
+
+.panel {
+ background: var(--panel);
+ border: none;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ color: var(--text);
+ border-radius: var(--radius);
+ box-shadow: var(--panel-shadow);
+}
+
+.control-panel {
+ width: 300px;
+ padding: 16px 20px 20px;
+ border-radius: 0;
+ border: none;
+ border-right: 1px solid var(--line);
+ background: var(--panel);
+ box-shadow: none;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+ gap: 16px;
+}
+
+.control-panel-title {
+ font-size: 28px;
+ font-weight: 100;
+ letter-spacing: 0.02em;
+ color: var(--text);
+ margin-bottom: 10px;
+}
+
+.meta-panel {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 1px;
+}
+.meta-panel label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--muted);
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+.meta-panel input {
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--text);
+ border-radius: var(--radius);
+ padding: 6px 8px;
+ font-size: 13px;
+}
+
+.quick-preset-panel label {
+ font-size: 12px;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.preset-preview {
+ padding: 10px;
+ border-radius: var(--radius);
+ border: 1px dashed var(--input-border);
+ background: var(--input-bg);
+}
+.preset-preview-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--text);
+}
+.preset-preview-desc {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.note-templates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.change-log {
+ border: 1px solid var(--panel-border);
+ border-radius: var(--radius);
+ padding: 12px;
+ background: var(--panel);
+ box-shadow: var(--panel-shadow);
+ gap: 6px;
+}
+.change-log ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.change-log li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--text);
+}
+.change-log li time {
+ font-size: 11px;
+ color: var(--muted);
+}
+.change-log-empty {
+ font-size: 12px;
+ color: var(--muted);
+ opacity: 0.7;
+}
+
+.id-manager-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ justify-content: flex-start;
+}
+.id-manager-panel .btn {
+ padding: 5px 10px;
+ min-height: 28px;
+}
+.id-manager-panel .btn.icon-square {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+}
+
+.dot-loader {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #ba473a;
+ display: inline-block;
+ animation: dotPulse 0.8s infinite alternate;
+ margin-right: 4px;
+}
+
+@keyframes dotPulse {
+ to {
+ transform: scale(0.6);
+ opacity: 0.4;
+ }
+}
+.panel.panel-left,
+.panel.panel-right {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding: 0;
+ border-radius: 0;
+}
+.panel-full {
+ grid-column: 1 / -1;
+}
+.panel.panel-left .blocks-container {
+ padding: 10px 8px 14px;
+}
+.builder-grid .panel.panel-left .blocks-container {
+ padding-bottom: 50vh;
+}
+.panel-sidebar {
+ align-self: stretch;
+ gap: 8px;
+}
+.panel-sidebar .sidebar-group { display: flex; flex-direction: column; gap: 6px; }
+.panel-sidebar .sidebar-title { margin-top: 16px; }
+.sidebar-divider {
+ padding: 16px 0;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ margin: 16px 0;
+}
+.sidebar-separator {
+ height: 1px;
+ background: var(--line);
+ margin: 16px 0;
+}
+.sidebar-divider .sidebar-title {
+ margin-top: 0;
+}
+.panel-sidebar .sidebar-title { font-size: 12px; color: var(--muted); font-weight: 700; }
+.panel-sidebar .sidebar-title.sidebar-title-accent {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding-left: 8px;
+ border-left: 2px solid var(--accent-secondary);
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.panel-sidebar .sidebar-title.sidebar-title-accent::before {
+ content: '↘';
+ color: var(--accent-secondary);
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1;
+}
+.sidebar-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 16px;
+}
+.sidebar-title-row .sidebar-title {
+ margin-top: 0;
+}
+.sidebar-collapse-btn { background: none; border: none; cursor: pointer; padding: 0; font: inherit; text-align: left; }
+.sidebar-collapse-btn:hover { opacity: 0.8; }
+.collapse-arrow { font-size: 10px; opacity: 0.5; transition: transform 0.2s; display: inline-block; }
+.collapse-arrow.collapsed { transform: rotate(-90deg); }
+.sidebar-title-btn {
+ padding: 0 6px;
+ font-size: 16px;
+ line-height: 1;
+ color: var(--muted);
+ opacity: 0.6;
+}
+.sidebar-title-btn:hover {
+ opacity: 1;
+}
+.sidebar-note {
+ margin-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.sidebar-note-textarea {
+ resize: none;
+ min-height: 84px;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ padding: 0;
+ outline: none;
+}
+.acard {
+ background: var(--panel);
+ border: none;
+ border-radius: var(--radius);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ box-shadow: var(--panel-shadow);
+}
+.acard-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.acard-badge {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #130F33;
+ background: var(--accent-secondary);
+ padding: 2px 8px;
+ border-radius: 4px;
+ line-height: 1.6;
+}
+.acard-actions { display: flex; gap: 6px; margin-top: 8px; }
+.acard-actions .btn { flex: 1; font-size: 12px; justify-content: center; text-align: center; padding: 6px 10px; font-weight: 600; box-sizing: border-box; }
+.acard-close {
+ all: unset;
+ cursor: pointer;
+ font-size: 18px;
+ line-height: 1;
+ color: var(--muted);
+ padding: 0 2px;
+ opacity: 0.5;
+ transition: opacity .15s;
+}
+.acard-close:hover { opacity: 1; }
+.acard-subject {
+ font-weight: 700;
+ font-size: 13px;
+ line-height: 1.35;
+ color: var(--text);
+}
+.acard-preheader {
+ font-size: 11px;
+ color: var(--muted);
+ line-height: 1.4;
+ font-style: italic;
+}
+.acard-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.acard-tag {
+ font-size: 10px;
+ padding: 2px 7px;
+ border-radius: 4px;
+ background: var(--panel);
+ border: 1px solid var(--pill-border);
+ color: var(--text);
+ white-space: nowrap;
+ line-height: 1.5;
+}
+.acard-links {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ border-top: 1px solid var(--pill-border);
+ padding-top: 8px;
+}
+.acard-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--muted);
+ text-decoration: none;
+ line-height: 1.4;
+ transition: opacity .15s;
+}
+.acard-link:hover { opacity: 0.7; }
+.acard-link svg { flex-shrink: 0; }
+.acard-mailing-btn {
+ background: var(--text);
+ color: var(--panel);
+ border-color: var(--text);
+}
+.acard-mailing-btn:hover {
+ background: var(--muted);
+ border-color: var(--muted);
+ color: var(--panel);
+ box-shadow: none;
+}
+.acard-plan { margin-top: 10px; border-top: 1px solid var(--pill-border); padding-top: 8px; }
+.acard-plan-title { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; cursor: pointer; }
+.acard-plan-title:hover { color: var(--text); }
+.acard-cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
+.acard-cal-month { font-size: 11px; font-weight: 600; color: var(--text); text-transform: capitalize; }
+.acard-cal-arrow { all: unset; cursor: pointer; font-size: 16px; color: var(--muted); padding: 0 4px; line-height: 1; }
+.acard-cal-arrow:hover { color: var(--text); }
+.acard-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; text-align: center; }
+.acard-cal-dow { font-size: 9px; color: var(--muted); padding: 2px 0; }
+.acard-cal-empty { padding: 2px; }
+.acard-cal-day {
+ position: relative; font-size: 10px; padding: 3px 0; border-radius: 3px;
+ color: var(--muted); line-height: 1.3; display: flex; flex-direction: column; align-items: center;
+}
+.acard-cal-day.acard-cal-today { font-weight: 700; color: var(--text); background: var(--drag); }
+.acard-cal-day.acard-cal-has { color: var(--text); }
+.acard-cal-day.acard-cal-clickable { cursor: pointer; }
+.acard-cal-day.acard-cal-clickable:hover { background: var(--drag); }
+.acard-cal-dots { display: flex; gap: 2px; justify-content: center; margin-top: 2px; }
+.acard-cal-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
+.acard-cal-dot.plan-dot-done { background: var(--success); }
+.acard-cal-dot.acard-cal-dot-past { background: var(--success); opacity: 0.35; }
+.acard-cal-dot.plan-dot-wip { background: #e09020; }
+.acard-cal-dot.plan-dot-queue { background: var(--accent); }
+.acard-plan-row {
+ display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 4px;
+ cursor: pointer; font-size: 11px; transition: background .15s;
+}
+.acard-plan-row:hover { background: var(--drag); }
+.acard-plan-date { color: var(--muted); font-size: 10px; white-space: nowrap; min-width: 42px; }
+.acard-plan-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: var(--accent); }
+.acard-plan-dot.plan-dot-done { background: var(--success); }
+.acard-plan-dot.plan-dot-wip { background: #e09020; }
+.acard-plan-dot.plan-dot-queue { background: var(--accent); }
+.acard-plan-subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
+.acard-plan-overdue .acard-plan-date { color: var(--error, #e74c3c); font-weight: 600; }
+.acard-plan-overdue .acard-plan-subject { color: var(--error, #e74c3c); }
+
+/* --- FTP Gallery Modal --- */
+.ftp-modal { max-width: 1100px; width: 92vw; }
+.ftp-modal-path { font-size: 11px; color: var(--muted); margin-bottom: 8px; font-family: monospace; }
+.ftp-upload-form {
+ display: flex; flex-direction: column; gap: 6px;
+ margin-bottom: 12px;
+}
+.ftp-upload-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.ftp-file-btn {
+ font-size: 11px; cursor: pointer; gap: 6px;
+}
+.ftp-file-btn.uploading { opacity: 0.7; animation: ftp-pulse 0.8s ease-in-out infinite alternate; cursor: default; }
+.ftp-progress { color: var(--muted); font-size: 10px; }
+.ftp-progressbar { height: 3px; background: var(--bg-alt); border-radius: 2px; overflow: hidden; margin-top: 6px; }
+.ftp-progressbar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; min-width: 4px; }
+.ftp-upload-preview-url {
+ font-size: 10px; color: var(--muted); font-family: monospace; word-break: break-all;
+}
+.ftp-upload-status {
+ font-size: 11px; color: var(--accent); margin-top: 4px;
+}
+@keyframes ftp-pulse { from { opacity: 0.5; } to { opacity: 1; } }
+.ftp-gallery-state { font-size: 12px; color: var(--muted); padding: 20px 0; text-align: center; }
+.ftp-gallery-error { color: var(--error); }
+.ftp-gallery-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 8px; max-height: 55vh; overflow-y: auto; padding: 2px;
+}
+.ftp-gallery-item {
+ border: 1px solid var(--line); border-radius: var(--radius);
+ overflow: hidden; transition: border-color .15s;
+}
+.ftp-gallery-item:hover { border-color: var(--accent); }
+.ftp-gallery-item-img { cursor: pointer; }
+.ftp-gallery-item img {
+ width: 100%; height: 100px; object-fit: contain; background: var(--bg-alt); display: block;
+}
+.ftp-gallery-item-footer {
+ display: flex; align-items: center; gap: 2px; padding: 0 4px 0 6px;
+}
+.ftp-gallery-item-name {
+ font-size: 10px; color: var(--muted); flex: 1;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.ftp-gallery-delete-btn {
+ flex-shrink: 0; background: none; border: none; cursor: pointer;
+ color: var(--muted); font-size: 14px; line-height: 1; padding: 2px 4px;
+ border-radius: var(--radius); opacity: 0;
+ transition: opacity .15s, color .15s;
+}
+.ftp-gallery-item:hover .ftp-gallery-delete-btn { opacity: 1; }
+.ftp-gallery-delete-btn:hover { color: var(--danger, #e53e3e); }
+.ftp-gallery-item.delete-pending { border-color: var(--danger, #e53e3e); }
+.ftp-gallery-item.delete-pending .ftp-gallery-item-img { opacity: 0.5; }
+.ftp-gallery-confirm-btn {
+ flex-shrink: 0; background: none; border: none; cursor: pointer;
+ font-size: 12px; line-height: 1; padding: 2px 4px; border-radius: var(--radius);
+ transition: color .15s;
+}
+.ftp-gallery-confirm-btn.yes { color: var(--danger, #e53e3e); }
+.ftp-gallery-confirm-btn.yes:hover { color: #c53030; }
+.ftp-gallery-confirm-btn.no { color: var(--muted); }
+.ftp-gallery-confirm-btn.no:hover { color: var(--text); }
+
+details.sidebar-group > summary.sidebar-title {
+ cursor: pointer;
+ user-select: none;
+}
+.sidebar-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: nowrap;
+}
+.control-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+}
+.notes-area {
+ width: 100%;
+ min-height: 110px;
+ padding: 6px 8px;
+ border-radius: var(--radius);
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--text);
+ resize: vertical;
+ font-size: 12px;
+ font-family: "JetBrains Mono", "SFMono-Regular", monospace;
+}
+
+.panel h1, .panel h2 { margin: 0 0 6px; font-weight: 700; color: var(--text); letter-spacing: 0.01em; }
+.panel h1 { font-size: 16px; }
+.panel h2 { font-size: 14px; }
+
+.toolbar {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 8px 10px;
+ align-items: end;
+ margin-bottom: 8px;
+ position: sticky;
+ top: 10px;
+ z-index: 5;
+ background: var(--panel);
+ padding: 4px 0 4px;
+}
+.toolbar-field { display: flex; flex-direction: column; gap: 6px; }
+.folder-field {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+}
+.folder-field .folder-btn { align-self: flex-start; width: 32px; }
+.folder-field .field-control { width: 100%; min-width: 360px; }
+.folder-actions { display: flex; align-items: center; gap: 4px; }
+.btn.icon-square {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ padding: 0;
+ justify-content: center;
+ font-size: 13px;
+ border-radius: var(--radius);
+ aspect-ratio: 1 / 1;
+ line-height: 1;
+}
+.btn:not(.icon-square) {
+ min-width: 32px;
+}
+.btn.icon-square.btn-textual {
+ width: auto;
+ min-width: 0;
+ height: 32px;
+ padding: 6px 8px;
+ justify-content: flex-start;
+ border-width: 1px;
+ aspect-ratio: unset;
+}
+.btn.icon-square.btn-textual.active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+ color: var(--accent);
+}
+.btn.icon-square.btn-textual.partial {
+ border-style: dashed;
+ color: var(--text);
+}
+.product-options-bar .btn[data-state="on"] {
+ border-color: var(--accent) !important;
+ color: var(--accent) !important;
+ box-shadow: 0 0 0 1px var(--accent) !important;
+}
+.product-options-bar .btn[data-state="partial"] {
+ border-color: var(--accent) !important;
+ border-style: dashed !important;
+ color: var(--accent) !important;
+}
+.btn.icon-square.triangle { font-size: 13px; }
+.btn.icon-square.danger {
+ border-color: #ba473a;
+ color: #ba473a;
+}
+.btn.icon-square.danger:hover { background: rgba(186, 71, 58, 0.14); border-color: #ba473a; }
+.small-actions { display: flex; align-items: center; gap: 6px; }
+
+.sidebar-row .folder-actions { flex: 0 0 auto; justify-content: flex-start; flex-wrap: nowrap; }
+.folder-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: nowrap;
+ min-height: 40px;
+ flex: 0 0 auto;
+}
+
+.field-label { font-size: 12px; color: var(--muted); letter-spacing: 0.01em; }
+.field-control {
+ padding: 7px 10px;
+ font-size: 13px;
+ min-width: 240px;
+ height: 34px;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--text);
+ border-radius: var(--radius);
+ box-shadow: none;
+ transition: border-color 150ms ease, box-shadow 150ms ease;
+}
+.field-control:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 15%, transparent); }
+.color-input {
+ width: 44px;
+ height: 34px;
+ padding: 0;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.hint { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
+.settings-section .hint { margin-bottom: 0; margin-top: -4px; }
+.status-badge, .plan-status-badge {
+ display: inline-flex; align-items: center; font-size: 11px; font-weight: 600;
+ padding: 3px 10px; border-radius: var(--radius); border: 1px solid var(--panel-border);
+ background: transparent; color: var(--muted);
+}
+.status-badge.ok, .plan-status-badge.ok { color: var(--success); border-color: var(--success); background: color-mix(in srgb, var(--success) 8%, transparent); }
+.status-badge.warn, .plan-status-badge.warn { color: #e09020; border-color: #e09020; background: color-mix(in srgb, #e09020 8%, transparent); }
+.status-badge.error, .plan-status-badge.error { color: var(--error); border-color: var(--error); background: color-mix(in srgb, var(--error) 8%, transparent); }
+
+.quick-blocks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+.quick-blocks .btn.icon-square.btn-textual {
+ height: 26px;
+ padding: 3px 8px;
+ font-size: 11px;
+}
+.quick-blocks-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+.quick-block-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 10px;
+ background: var(--bg-alt);
+ border: 1px solid var(--panel-border);
+ border-radius: var(--radius);
+ font-size: 12px;
+ font-weight: 500;
+ transition: border-color 0.12s;
+}
+.quick-block-pill:hover { border-color: var(--muted); }
+.quick-block-pill[draggable], .quick-blocks .btn[draggable] { cursor: grab; }
+.quick-block-pill[draggable]:active, .quick-blocks .btn[draggable]:active { cursor: grabbing; }
+.qb-inline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 10px 12px 14px;
+ border-top: 1px dashed var(--panel-border);
+ margin-top: 4px;
+}
+.qb-inline-btn {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ font-size: 12px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius);
+ letter-spacing: -0.5px;
+}
+.qb-inline-btn svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+ flex-shrink: 0;
+}
+.qb-inline-btn[draggable] { cursor: grab; }
+.qb-inline-btn[draggable]:active { cursor: grabbing; }
+.qb-add-btn {
+ height: 32px;
+ padding: 0 16px;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: 18px;
+}
+.qb-dragging { opacity: 0.4; }
+.qb-drag-over { border-color: var(--accent) !important; box-shadow: 0 0 0 2px var(--accent-muted, #b2dfdb); }
+.qb-drag-handle { color: var(--muted); font-size: 14px; cursor: grab; user-select: none; }
+.qb-color-dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--panel-border); background: var(--bg); cursor: pointer; flex-shrink: 0; padding: 0; transition: border-color 0.12s, transform 0.1s; }
+.qb-color-dot:hover { transform: scale(1.2); }
+.qb-color-dot.active { border-color: var(--accent) !important; box-shadow: 0 0 0 2px var(--accent); }
+.quick-block-pill .btn.icon-square {
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ font-size: 12px;
+ padding: 0;
+}
+.field-error {
+ font-size: 12px;
+ color: var(--error);
+}
+.field-success {
+ font-size: 12px;
+ color: var(--success);
+}
+
+.btn {
+ padding: 7px 14px;
+ font-size: 13px;
+ border: 1px solid var(--accent);
+ background: var(--panel);
+ color: var(--text);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border-radius: var(--radius);
+ transition: all 150ms ease;
+ font-weight: 500;
+ min-height: 32px;
+ box-shadow: none;
+}
+.btn:hover {
+ background: var(--accent);
+ color: var(--accent-contrast);
+ border-color: var(--accent);
+ box-shadow: none;
+}
+.btn:active {
+ transform: scale(0.98);
+}
+.btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+.btn.reset-btn {
+ border-color: #ba473a;
+ color: #ba473a;
+ background: transparent;
+ font-weight: 600;
+}
+.btn.reset-btn:hover {
+ box-shadow: 0 0 0 1px #ba473a;
+ border-color: #ba473a;
+ color: #ba473a;
+}
+.btn.reset-btn:disabled {
+ opacity: 0.35;
+ color: rgba(186, 71, 58, 0.55);
+ border-color: rgba(186, 71, 58, 0.4);
+ background: transparent;
+}
+.btn.ghost { background: transparent; border-style: dashed; border-color: var(--muted); color: var(--muted); box-shadow: none; }
+.btn.ghost:hover {
+ border-color: var(--accent);
+ background: transparent;
+ box-shadow: none;
+ color: var(--accent);
+}
+.folder-btn { font-weight: 600; }
+.folder-field .folder-btn { min-width: 38px; min-height: 32px; padding: 0; justify-content: center; }
+#copy-btn.btn-primary, #copy-btn-top.btn-primary, #export-btn.btn-primary, #export-btn-top.btn-primary {
+ background: transparent;
+ border-color: var(--accent);
+ color: var(--accent);
+}
+#copy-btn.btn-primary:hover, #copy-btn-top.btn-primary:hover, #export-btn.btn-primary:hover, #export-btn-top.btn-primary:hover {
+ box-shadow: 0 0 0 1px var(--accent);
+}
+.pug-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 0;
+}
+.pug-actions .btn {
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+}
+.pug-actions .btn:hover {
+ border-color: var(--accent);
+ background: transparent;
+ box-shadow: 0 0 0 1px var(--accent);
+}
+.pug-actions .btn.danger {
+ color: #ba473a;
+ border: none;
+ background: transparent;
+}
+.pug-actions .btn.danger:hover {
+ border-color: #ba473a;
+ box-shadow: 0 0 0 1px #ba473a;
+ background: transparent;
+}
+
+.blocks-toolbar {
+ display: flex;
+ gap: 6px;
+ padding: 6px 0 0;
+}
+
+.blocks-container {
+ margin-top: 6px;
+ border-top: none;
+ padding-top: 0;
+ overflow-y: auto;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.block-card.flash {
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.segment-divider {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ padding: 2px 0;
+}
+.segment-divider::before,
+.segment-divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--line);
+ opacity: 0.4;
+}
+.segment-divider span {
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.block-card {
+ border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
+ padding: 9px 10px 10px 14px;
+ background: var(--panel);
+ color: var(--text);
+ border-radius: var(--radius);
+ box-shadow: none;
+ transition: background 140ms ease, box-shadow 200ms ease, border-color 140ms ease, transform 140ms ease;
+ position: relative;
+ flex: 0 0 auto;
+}
+.block-card:hover {
+ border-color: color-mix(in srgb, var(--text) 30%, transparent);
+}
+.block-card.dragging {
+ background: var(--pill);
+ transform: scale(0.98);
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ cursor: grabbing;
+}
+.block-card.dragging * { cursor: grabbing; }
+.block-card.block-dragging {
+ opacity: 0.4;
+}
+.block-card.block-drag-over {
+ border-top: 3px solid var(--accent, #6b7280);
+ margin-top: -3px;
+}
+.drag-handle {
+ cursor: grab;
+ opacity: 0.35;
+ margin-right: 4px;
+ user-select: none;
+ font-size: 12px;
+}
+.drag-handle:hover { opacity: 0.7; }
+.block-card.collapsed {
+ padding: 8px 12px 8px 16px;
+ min-height: 40px;
+ overflow: hidden;
+}
+.block-card.collapsed .block-title { opacity: 0.92; }
+.block-card.collapsed .block-header { margin-bottom: 0; }
+.block-card::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 8px;
+ bottom: 8px;
+ width: 2px;
+ border-radius: 999px;
+ background: transparent;
+ opacity: 0.85;
+}
+.block-card.marker-female::before { background: #ba473a; }
+.block-card.marker-male::before { background: #2a6cc6; }
+.block-card.marker-common::before { background: #6b7b70; opacity: 0.4; }
+.block-card.marker-center::before { background: #9ca3af; opacity: 0.6; }
+
+.block-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: -2px -4px 6px;
+ gap: 6px;
+ padding: 4px 8px;
+ background: transparent;
+ border-bottom: none;
+ border-radius: 0;
+}
+.block-card.collapsed .block-header { margin: 0; }
+.block-card.collapsed .block-header { border-bottom: none; }
+.block-title { font-weight: 600; font-size: 12px; color: var(--text); display: flex; align-items: center; gap: 6px; letter-spacing: 0.01em; }
+.block-num { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; border: 1px solid var(--panel-border); font-size: 10px; font-weight: 700; padding: 0 4px; color: var(--muted); flex-shrink: 0; }
+.block-title.clickable { cursor: pointer; }
+.template-override-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
+.template-custom-badge { font-size: 10px; font-weight: 600; color: #130F33; background: color-mix(in srgb, var(--accent-secondary) 40%, transparent); padding: 1px 5px; border-radius: 4px; flex-shrink: 0; }
+.block-actions { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; }
+
+.collapse-btn {
+ border: 1px solid var(--panel-border);
+ background: var(--pill);
+ color: var(--text);
+ border-radius: var(--radius);
+ width: 26px;
+ height: 24px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ font-size: 12px;
+ transition: background 120ms ease, border-color 120ms ease;
+}
+.collapse-btn:hover { background: var(--bg-alt); border-color: var(--muted); }
+
+.segment-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 22px;
+ padding: 0 3px;
+ margin-right: 2px;
+ border-radius: var(--radius);
+ background: transparent;
+ border: none;
+ color: var(--text);
+ font-weight: 700;
+ font-size: 10px;
+ line-height: 1.1;
+ font-family: "JetBrains Mono", "SFMono-Regular", monospace;
+}
+
+.segment-switch { display: flex; align-items: center; gap: 8px; margin: 6px 0 10px; }
+.segment-toggle {
+ display: inline-flex;
+ border: none;
+ border-radius: var(--radius);
+ overflow: visible;
+ background: transparent;
+ gap: 2px;
+}
+.segment-toggle button {
+ background: transparent;
+ color: var(--muted);
+ border: none;
+ padding: 0 5px;
+ height: 24px;
+ min-width: 22px;
+ font-size: 11px;
+ cursor: pointer;
+ line-height: 1.2;
+ border-radius: var(--radius);
+ transition: all 120ms ease;
+}
+.segment-toggle button:focus,
+.segment-toggle button:focus-visible {
+ outline: none;
+ box-shadow: none;
+ border-radius: var(--radius);
+}
+.segment-toggle button + button { border-left: none; }
+.segment-toggle button.active {
+ font-weight: 700;
+ border-radius: var(--radius);
+}
+.segment-toggle button:nth-child(1).active { color: #4b4c46; border-color: #4b4c46; }
+.segment-toggle button:nth-child(2).active { color: #ba473a; border-color: #ba473a; }
+.segment-toggle button:nth-child(3).active { color: #2a6cc6; border-color: #2a6cc6; }
+.segment-toggle.compact { transform: none; }
+
+.block-actions button {
+ margin-left: 2px;
+ padding: 0 5px;
+ height: 22px;
+ min-height: 22px;
+ font-size: 11px;
+ background: transparent;
+ color: var(--muted);
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all 120ms ease;
+}
+.block-actions button:hover { background: rgba(107, 114, 128, 0.1); color: var(--text); border-color: var(--accent); }
+.block-actions .segment-toggle button {
+ background: transparent;
+ color: var(--muted);
+ border: none;
+ padding: 0 6px;
+ height: 24px;
+ min-width: 22px;
+ font-size: 11px;
+ border-radius: var(--radius);
+}
+.block-actions .segment-toggle button:hover { background: transparent; color: var(--text); }
+.block-actions .segment-toggle button:focus,
+.block-actions .segment-toggle button:focus-visible {
+ outline: none;
+ box-shadow: none;
+ border-radius: var(--radius);
+}
+.block-actions .segment-toggle button:nth-child(1).active { background: transparent; color: #4b4c46; border-color: #4b4c46; border-radius: var(--radius); }
+.block-actions .segment-toggle button:nth-child(2).active { background: transparent; color: #ba473a; border-color: #ba473a; border-radius: var(--radius); }
+.block-actions .segment-toggle button:nth-child(3).active { background: transparent; color: #2a6cc6; border-color: #2a6cc6; border-radius: var(--radius); }
+.block-actions .icon-btn {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ justify-content: center;
+ align-items: center;
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+.block-actions .icon-btn svg { display: block; }
+.block-actions .icon-btn.danger { color: #ba473a; }
+.block-actions .icon-btn.danger:hover { background: rgba(186,71,58,0.18); color: #ba473a; }
+
+.block-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 10px;
+ font-size: 12px;
+ color: var(--muted);
+ margin: 0 0 4px;
+}
+.block-summary {
+ flex: 0 1 220px;
+ font-family: "JetBrains Mono", "SFMono-Regular", monospace;
+ font-size: 10px;
+ color: var(--muted);
+ opacity: 0.9;
+}
+.block-issues {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-left: auto;
+}
+.issue-badge {
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ background: transparent;
+ border: 1px solid rgba(186, 71, 58, 0.35);
+ color: #ba473a;
+}
+
+.drag-placeholder { height: 8px; margin: 8px 0; background: var(--drag); border-radius: 999px; }
+.block-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 18px; cursor: grab; color: #9b9b97; user-select: none; }
+.block-drag-handle:active { cursor: grabbing; }
+
+.block-section-divider { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 4px 0 2px; border-top: 1px solid var(--border); margin-top: 4px; }
+.block-section-divider:first-child { border-top: none; margin-top: 0; }
+.section-label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.section-actions-inline { display: inline-flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity 120ms ease; }
+.block-card:hover .section-actions-inline { opacity: 0.35; }
+.section-actions-inline:hover { opacity: 1; }
+
+.block-body { font-size: 13px; display: grid; gap: 6px; }
+.block-body label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
+
+.block-body input[type="checkbox"],
+.block-body input[type="radio"],
+input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
+
+.block-body input[type="text"],
+.block-body input[type="number"],
+.block-body textarea,
+select.field-control {
+ width: 100%;
+ padding: 8px 10px;
+ font-size: 13px;
+ margin-top: 2px;
+ border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
+ background: var(--input-bg);
+ color: var(--text);
+ border-radius: var(--radius);
+ box-shadow: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+.block-body input[type="text"]:focus,
+.block-body input[type="number"]:focus,
+.block-body textarea:focus,
+select.field-control:focus {
+ outline: none;
+ border-color: color-mix(in srgb, var(--text) 25%, transparent);
+ box-shadow: none;
+}
+
+.block-body textarea { min-height: 70px; resize: vertical; }
+.block-body .singleline-textarea { min-height: 36px; height: 36px; resize: none; }
+.block-body .inline { display: flex; align-items: center; gap: 10px; margin: 2px 0; }
+.block-body .inline label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ padding: 4px 8px;
+ background: transparent;
+ border: 1px solid var(--panel-border);
+ border-radius: 999px;
+ font-size: 12px;
+}
+.spacing-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 8px;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: var(--radius);
+ width: fit-content;
+ background: transparent;
+ margin-top: 0;
+ line-height: 1;
+ height: 32px;
+}
+.spacing-toggle {
+ margin: 0;
+ line-height: 1;
+ height: 32px;
+ width: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+ padding: 0;
+ cursor: pointer;
+}
+.spacing-control.active {
+ border-color: var(--accent);
+ box-shadow: none;
+}
+.spacing-control.active .spacing-toggle {
+ color: var(--muted);
+}
+.spacing-value {
+ width: 3ch;
+ min-width: 3ch;
+ max-width: 3ch;
+ text-align: right;
+ line-height: 1;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ border: none !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ outline: none;
+ appearance: none;
+ -webkit-appearance: none;
+ color: var(--text);
+ font-size: 12px;
+ padding: 0;
+ border-radius: 0;
+}
+.spacing-value:focus { outline: none; }
+.spacing-value:disabled { opacity: 0.5; }
+.block-body .inline .inline-number {
+ width: 64px;
+ min-width: 0;
+ padding: 6px 8px;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible {
+ outline: 2px solid rgba(43,47,52,0.35);
+ outline-offset: 2px;
+}
+
+.code-panel-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--panel-border);
+ margin-bottom: 8px;
+}
+.code-panel-header > div:first-child { border-left: 3px solid var(--accent-secondary); padding-left: 10px; }
+.code-panel-title { font-size: 18px; font-weight: 600; letter-spacing: 0.01em; }
+.code-panel-subtitle { font-size: 12px; color: var(--muted); }
+.code-panel-actions {
+ display: flex;
+ gap: 4px;
+ flex-wrap: nowrap;
+ align-items: center;
+}
+.actions-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--panel-border);
+ margin: 0 4px;
+ flex-shrink: 0;
+}
+.code-panel-actions .btn {
+ font-size: 12px;
+ padding: 5px 10px;
+ min-height: 28px;
+ border-style: solid;
+ border-color: var(--panel-border);
+ background: var(--pill);
+}
+.code-panel-actions .btn:hover {
+ background: var(--pill);
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.code-panel-actions .btn.toggled {
+ background: var(--accent);
+ color: var(--accent-contrast);
+ border-color: var(--accent);
+}
+
+textarea#pug-output,
+textarea#html-output {
+ width: 100%;
+ margin-top: 6px;
+ padding: 14px;
+ font-family: "JetBrains Mono", Menlo, Consolas, monospace;
+ font-size: 12px;
+ border: 1px solid var(--code-border);
+ background: var(--code-bg);
+ color: var(--text);
+ resize: none;
+ white-space: pre;
+ min-height: 0;
+ max-height: none;
+ height: auto;
+ overflow: auto;
+ border-radius: var(--radius);
+ box-shadow: none;
+}
+
+.preview-panel {
+ width: min(100%, 600px);
+ margin: 0 auto;
+ border: none;
+ background: transparent;
+ border-radius: var(--radius);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 0;
+ align-items: stretch;
+ flex: 1;
+ min-height: 0;
+}
+
+.preview-zoom-overlay {
+ position: absolute;
+ bottom: 10px;
+ right: 10px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(0,0,0,0.45);
+ backdrop-filter: blur(6px);
+ border-radius: 20px;
+ padding: 4px 10px 4px 8px;
+ z-index: 10;
+ opacity: 0.35;
+ transition: opacity 0.2s;
+}
+.preview-zoom-overlay:hover {
+ opacity: 1;
+}
+.preview-zoom-overlay input[type="range"] {
+ width: 80px;
+ height: 3px;
+ accent-color: #fff;
+ cursor: pointer;
+}
+.preview-zoom-overlay span {
+ font-size: 11px;
+ color: #fff;
+ min-width: 28px;
+ font-variant-numeric: tabular-nums;
+}
+.preview-meta {
+ font-size: 12px;
+ color: var(--muted);
+}
+/* ── Нет в наличии ── */
+.preview-warning {
+ border: 1px solid #fca5a5;
+ background: #fff5f5;
+ border-radius: 0;
+ margin-top: 6px;
+ overflow: hidden;
+}
+.unavailable-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ padding: 8px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #b91c1c;
+ gap: 6px;
+}
+.unavailable-header:hover {
+ background: #fee2e2;
+}
+.unavailable-toggle {
+ font-size: 11px;
+ color: #b91c1c;
+ opacity: 0.7;
+ flex-shrink: 0;
+}
+.unavailable-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border-top: 1px solid #fecaca;
+ font-size: 12px;
+ background: #ffffff;
+}
+.unavailable-item:hover {
+ background: #fafafa;
+}
+.unavailable-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #111;
+ font-weight: 500;
+}
+.unavailable-meta {
+ color: #888;
+ font-weight: 400;
+}
+.btn-xs {
+ font-size: 11px;
+ padding: 3px 9px;
+ border-radius: 0;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ── Панель замен ── */
+.suggest-panel {
+ background: #fff;
+ border: 1px solid #e5e5e5;
+ border-radius: 0;
+ margin-top: 6px;
+ max-height: 340px;
+ overflow-y: auto;
+}
+.suggest-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 9px 12px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #111;
+ border-bottom: 1px solid #e5e5e5;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 1;
+}
+.suggest-header .btn-xs {
+ color: #888;
+ border-color: #ddd;
+ background: #f5f5f5;
+ font-size: 14px;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+.suggest-search {
+ display: flex;
+ gap: 6px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #e5e5e5;
+ background: #fafafa;
+ position: sticky;
+ top: 35px;
+ z-index: 1;
+}
+.suggest-search input {
+ flex: 1;
+ font-size: 12px;
+ padding: 5px 8px;
+ border: 1px solid #ddd;
+ border-radius: 0;
+ background: #fff;
+ color: #111;
+ outline: none;
+}
+.suggest-search input:focus { border-color: #aaa; }
+.suggest-loading, .suggest-empty {
+ padding: 20px 12px;
+ font-size: 12px;
+ color: #aaa;
+ text-align: center;
+}
+.suggest-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ cursor: pointer;
+ border-bottom: 1px solid #f0f0f0;
+ transition: background 0.1s;
+}
+.suggest-item:last-child { border-bottom: none; }
+.suggest-item:hover { background: #f7f7f7; }
+.suggest-img {
+ width: 48px;
+ height: 58px;
+ object-fit: contain;
+ flex-shrink: 0;
+ border-radius: 0;
+ background: #f5f5f5;
+}
+.suggest-info {
+ flex: 1;
+ min-width: 0;
+}
+.suggest-name {
+ font-size: 12px;
+ font-weight: 500;
+ color: #111;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+.suggest-price {
+ font-size: 13px;
+ font-weight: 700;
+ color: #111;
+ margin-top: 4px;
+}
+.suggest-id {
+ font-size: 11px;
+ color: #aaa;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ── Hover-попап ── */
+.suggest-hover-popup {
+ position: fixed; z-index: 9999; pointer-events: none;
+ background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
+ box-shadow: 0 12px 48px rgba(0,0,0,0.22); padding: 14px; display: flex; gap: 16px;
+ max-width: 420px;
+}
+.suggest-hover-img { width: 220px; height: 280px; object-fit: contain; border-radius: 4px; flex-shrink: 0; background: var(--pill); }
+.suggest-hover-info { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding-top: 4px; }
+.suggest-hover-name { font-size: 14px; font-weight: 600; line-height: 1.35; }
+.suggest-hover-row { font-size: 12px; color: var(--muted); }
+.suggest-hover-price { font-size: 18px; font-weight: 700; margin-top: 8px; }
+
+.preview-empty {
+ font-size: 13px;
+ color: var(--muted);
+ padding: 8px 0;
+}
+
+.email-preview-viewport {
+ width: min(100%, 600px);
+ margin: 0 auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: #fff;
+ overflow-x: hidden;
+ overflow-y: auto;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 8px;
+ flex: 1;
+ min-height: 200px;
+ transition: border-color 0.6s ease;
+}
+.email-preview-viewport.preview-flash {
+ border-color: var(--success);
+ transition: border-color 0.1s ease;
+}
+
+.email-preview-scale-shell {
+ position: relative;
+ flex: 0 0 auto;
+ overflow: hidden;
+ max-width: 100%;
+}
+
+.email-preview-frame {
+ display: block;
+ border: 0;
+ border-radius: 0;
+ background: #fff;
+ transform-origin: top left;
+}
+
+.copy-status { margin-top: 4px; font-size: 12px; color: var(--success); }
+.code-collapsed-hint {
+ border: 1px dashed var(--panel-border);
+ border-radius: var(--radius);
+ padding: 16px;
+ font-size: 13px;
+ color: var(--muted);
+ background: var(--panel);
+}
+
+.footer-note { margin: 8px 0 16px; text-align: left; font-size: 11px; color: var(--muted); opacity: 0.85; }
+.footer-link { color: var(--text); text-decoration: none; border-bottom: 1px dashed var(--muted); }
+.footer-link:hover { color: var(--accent); }
+
+@media (max-width: 1200px) {
+ .workspace { padding: 0 20px 24px; }
+ .workspace-grid { grid-template-columns: 1fr; }
+ .panel-right { max-width: 100%; }
+}
+@media (max-width: 900px) {
+ .settings-two-col { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 960px) {
+ .notion-layout { flex-direction: column; }
+ .control-panel {
+ width: 100%;
+ height: auto;
+ position: relative;
+ border-right: none;
+ border-bottom: 1px solid var(--panel-border);
+ border-radius: 0 0 12px 12px;
+ }
+ .workspace { padding-top: 20px; }
+ .workspace-header { position: relative; }
+ .field-control { min-width: 240px; }
+}
+
+.preset-list { display: grid; gap: 10px; }
+.preset-card {
+ border: 1px solid var(--line);
+ background: var(--card);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border-radius: var(--radius);
+ box-shadow: none;
+}
+.preset-title { font-weight: 700; color: var(--text); letter-spacing: 0.01em; }
+.preset-desc { font-size: 13px; color: var(--muted); }
+.preset-fields { display: grid; gap: 10px; margin: 4px 0; }
+.html-pug-fields {
+ margin: 8px 0 10px;
+}
+.html-pug-fields .html-pug-label {
+ display: block;
+ font-weight: 100;
+ font-size: 11px;
+ color: var(--muted);
+}
+.html-pug-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.html-pug-path {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+#html-pug-path {
+ margin-top: 0;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+ padding-left: 0;
+ padding-right: 0;
+}
+#html-pug-path:focus {
+ border-bottom-color: var(--accent);
+ box-shadow: none;
+}
+#html-pug-preheader {
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+ padding-left: 0;
+ padding-right: 0;
+}
+#html-pug-preheader:focus {
+ border-bottom-color: var(--accent);
+ box-shadow: none;
+}
+.html-pug-grow {
+ flex: 1 1 auto;
+}
+.html-pug-actions {
+ align-items: center;
+ gap: 8px;
+ padding: 0 4px;
+}
+.save-status {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--success);
+}
+.html-pug-actions .btn-inline-row {
+ margin-top: 10;
+}
+.html-pug-actions .btn-inline {
+ height: 26px;
+ min-height: 26px;
+ line-height: 1;
+}
+.html-pug-gender .btn-inline:hover {
+ background: transparent;
+ border-color: var(--line);
+ color: var(--text);
+}
+.html-pug-copy.btn.icon-square.btn-textual {
+ margin-left: auto;
+ height: 26px;
+ min-height: 26px;
+ padding: 4px 8px;
+ line-height: 1;
+}
+.html-pug-gender .btn-inline.female.active,
+.html-pug-gender .btn-inline.female.active:hover {
+ color: #ba473a;
+ border-color: #ba473a;
+}
+.html-pug-gender .btn-inline.male.active,
+.html-pug-gender .btn-inline.male.active:hover {
+ color: #2a6cc6;
+ border-color: #2a6cc6;
+}
+.autosave-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ margin-left: 6px;
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity 0.4s ease, background 0.4s ease, border-color 0.4s ease, transform 0.4s ease, box-shadow 0.4s ease;
+}
+.autosave-dot.active {
+ opacity: 1;
+ background: var(--success);
+ border-color: var(--success);
+ transform: scale(1);
+ box-shadow: 0 0 6px rgba(107, 114, 128, 0.3);
+}
+.autosave-dot.error {
+ opacity: 1;
+ background: var(--error);
+ border-color: var(--error);
+ transform: scale(1);
+ box-shadow: 0 0 6px rgba(224, 82, 82, 0.6);
+}
+.save-error-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--error);
+ border: 1px solid var(--error);
+ border-radius: 4px;
+ padding: 0 6px;
+ height: 22px;
+ margin-left: 2px;
+ white-space: nowrap;
+}
+.save-error-dismiss {
+ background: none;
+ border: none;
+ color: var(--error);
+ cursor: pointer;
+ padding: 0;
+ font-size: 14px;
+ line-height: 1;
+ opacity: 0.7;
+}
+.save-error-dismiss:hover {
+ opacity: 1;
+}
+.preset-fields textarea {
+ min-height: 70px;
+ resize: vertical;
+ padding: 8px 10px;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--text);
+ min-width: 320px;
+ width: 100%;
+ border-radius: 1px;
+}
+.note-textarea {
+ width: 100%;
+ min-width: 0;
+ resize: none;
+ overflow: hidden;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ padding: 0;
+ outline: none;
+}
+.preset-fields textarea.note-textarea {
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ outline: none;
+}
+.preset-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.preset-row label { width: 100%; }
+.preset-advanced { margin-top: 6px; }
+
+.custom-presets { display: grid; gap: 8px; }
+.custom-preset-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--card);
+}
+.custom-preset-name { font-weight: 600; color: var(--text); }
+.custom-preset-actions button { margin-left: 6px; }
+
+.btn-inline {
+ margin-top: 2px;
+ padding: 4px 8px;
+ font-size: 11px;
+ border: 1px solid var(--line);
+ background: var(--pill);
+ color: var(--text);
+ cursor: pointer;
+ border-radius: var(--radius);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 34px;
+ width: auto;
+ align-self: flex-start;
+ justify-self: flex-start;
+ grid-column: auto;
+ max-width: 80px;
+}
+.btn-inline:hover {
+ background: #2f8f55;
+ border-color: #2f8f55;
+ color: var(--accent-contrast);
+}
+.btn-inline.active {
+ background: transparent;
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.btn-inline.active:hover {
+ background: transparent;
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.btn-inline.push-right { margin-left: auto; }
+.btn-inline-row {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-top: 2px;
+}
+
+.btn-inline.vk-btn {
+ box-sizing: border-box;
+ height: 34px;
+ padding: 0 4px;
+ min-width: 24px;
+ background: transparent;
+ border: none;
+ line-height: 1;
+}
+.btn-inline.vk-btn:hover {
+ background: transparent;
+ border: none;
+ color: var(--text);
+}
+.btn-inline.vk-btn.active,
+.btn-inline.vk-btn.active:hover {
+ background: transparent;
+ border: none;
+ color: var(--accent);
+}
+
+.advanced-wrapper { margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
+.btn-advanced-toggle {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ font-size: 11px;
+ padding: 0;
+ cursor: pointer;
+ text-decoration: underline dotted;
+ margin-bottom: 4px;
+}
+.btn-advanced-toggle.is-open { color: var(--text); }
+.advanced-panel label { margin-bottom: 6px; font-size: 11px; }
+
+.block-preview {
+ margin: 6px auto 10px;
+ padding: 10px;
+ border: 1px dashed var(--line);
+ background: var(--card);
+ color: var(--text);
+ min-height: 40px;
+ width: 540px;
+ max-width: 100%;
+ text-align: center;
+}
+.paragraph-textarea {
+ font-size: 18px;
+ line-height: 27px;
+ font-family: Helvetica, Arial, sans-serif;
+}
+.paragraph-preview {
+ font-size: 18px;
+ line-height: 27px;
+ font-family: Helvetica, Arial, sans-serif;
+}
+
+.theme-light textarea#pug-output,
+.theme-light textarea#html-output,
+.theme-light .field-control,
+.theme-light .preset-fields textarea,
+.theme-light .block-body input[type="text"],
+.theme-light .block-body input[type="number"],
+.theme-light .block-body textarea { background: var(--input-bg); color: var(--text); }
+.block-card:focus-within { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
+.icon {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 0.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ flex-shrink: 0;
+}
+.icon-img {
+ width: 16px;
+ height: 16px;
+ display: block;
+ opacity: 0.7;
+}
+body.theme-dark .icon-img {
+ filter: invert(1);
+}
+.btn:hover .icon-img {
+ filter: invert(1);
+}
+body.theme-dark .btn:hover .icon-img {
+ filter: invert(0);
+}
+.icon-svg {
+ width: 16px;
+ height: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.icon-svg svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+.icon-svg.accent {
+ color: var(--accent);
+}
+
+/* Brand tweaks */
+.brand { display: flex; flex-direction: column; gap: 6px; position: relative; }
+.brand-button {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 0;
+ border: 0;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+.brand-button:focus-visible {
+ outline: 2px solid rgba(43,47,52,0.35);
+ outline-offset: 2px;
+ border-radius: var(--radius);
+}
+.brand-mark { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: 0.02em; color: var(--project-accent, var(--text)); }
+.brand-sub { margin: 0; font-size: 18px; font-weight: 500; color: var(--muted); display: flex; align-items: center; gap: 5px; }
+.brand-sub::before { content: '✦'; color: var(--accent-secondary); font-size: 14px; }
+.brand-logo {
+ width: 128px;
+ height: auto;
+ max-height: 56px;
+ object-fit: contain;
+ filter: drop-shadow(0 0 0 transparent);
+}
+.brand-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ display: grid;
+ gap: 4px;
+ min-width: 180px;
+ padding: 6px;
+ border-radius: var(--radius);
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ box-shadow: 0 12px 28px rgba(0,0,0,0.12);
+ z-index: 30;
+}
+.brand-menu-item {
+ width: 100%;
+ text-align: left;
+ padding: 8px 10px;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+}
+.brand-menu-item:hover {
+ background: var(--pill);
+}
+.brand-menu-item.selected {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.preset-create,
+.add-block {
+ display: grid;
+ gap: 6px;
+}
+
+.field-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.editor-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+ width: 100%;
+}
+
+.link-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.link-input {
+ min-width: 160px;
+}
+
+.text-preview {
+ margin-top: 6px;
+}
+
+.text-preview-frame {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius);
+ padding: 10px;
+ color: var(--text);
+ max-width: 600px;
+}
+
+textarea.code {
+ width: 100%;
+ min-height: 300px;
+ resize: vertical;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--text);
+ border-radius: var(--radius);
+ padding: 10px;
+ font-family: "JetBrains Mono", Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.block-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 6px;
+ align-items: center;
+ width: 100%;
+}
+
+.btn.icon-square.active {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.icon-eye {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.add-inline {
+ min-width: 220px;
+}
+
+/* Weight + spacing tweaks */
+.block-footer { gap: 8px; }
+.block-footer .product-options-inline {
+ margin: 0;
+ margin-right: auto;
+ align-items: center;
+}
+.btn { font-weight: 500; }
+.panel-title,
+.workspace-title,
+.block-title,
+.sidebar-title,
+label,
+.brand-mark,
+.workspace-label {
+ font-weight: 500;
+}
+
+.panel.panel-right {
+ position: sticky;
+ top: 80px;
+ height: calc(100vh - 100px);
+ align-self: start;
+ display: flex;
+ flex-direction: column;
+}
+
+#pug-output,
+#html-output {
+ width: 100%;
+ flex: 1 1 auto;
+ height: 100%;
+}
+
+.quick-edit-popup {
+ position: fixed;
+ z-index: 1200;
+ width: 340px;
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 32px rgba(0,0,0,0.28);
+ padding: 10px 12px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.quick-edit-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.quick-edit-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.quick-edit-close {
+ flex: 0 0 auto;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 14px;
+ padding: 0 2px;
+ line-height: 1;
+}
+.quick-edit-close:hover { color: var(--text); }
+.quick-edit-textarea {
+ width: 100%;
+ resize: vertical;
+ min-height: 64px;
+ padding: 8px 10px;
+ border: 1px solid var(--input-border);
+ border-radius: var(--radius);
+ background: var(--input-bg);
+ color: var(--text);
+ font-family: "Inter", system-ui, sans-serif;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.linebreak-preview {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.linebreak-label {
+ display: block;
+ margin-bottom: 4px;
+}
+
+.linebreak-content {
+ background: var(--code-bg);
+ border: 1px dashed var(--code-border);
+ border-radius: var(--radius);
+ padding: 6px 8px;
+ color: var(--text);
+ font-family: "JetBrains Mono", Menlo, Consolas, monospace;
+ font-size: 12px;
+}
+
+.linebreak-content .newline {
+ display: inline-block;
+ padding: 0 4px;
+ margin: 0 2px;
+ border-radius: 4px;
+ background: rgba(226, 75, 91, 0.2);
+ color: #e24b5b;
+}
+
+/* Search in letters list */
+.search-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 10px;
+ border: none;
+ border-bottom: 1px solid var(--panel-border);
+ background: transparent;
+ color: var(--text);
+ font-size: 13px;
+ outline: none;
+ margin-bottom: 8px;
+}
+.search-input::placeholder {
+ color: var(--muted);
+}
+
+/* History dropdown */
+.history-dropdown {
+ width: 100%;
+ margin-top: 8px;
+ border-top: 1px solid var(--panel-border);
+ padding-top: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.history-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 5px 8px;
+ background: none;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 12px;
+ color: var(--text);
+ cursor: pointer;
+}
+.history-item:hover {
+ background: var(--hover);
+}
+.history-item.muted {
+ color: var(--muted);
+ cursor: default;
+}
+.history-item.muted:hover {
+ background: none;
+}
+
+/* Startup modal */
+.startup-letters-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 4px;
+ max-height: 280px;
+ overflow-y: auto;
+}
+.startup-letter-btn {
+ text-align: left;
+ justify-content: flex-start;
+}
+.startup-last-letter-btn {
+ width: 100%;
+ text-align: left;
+ justify-content: flex-start;
+ font-weight: 600;
+}
+.startup-plan-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.startup-plan-btn {
+ text-align: left;
+ justify-content: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+.startup-plan-date { font-size: 12px; color: var(--muted); white-space: nowrap; min-width: 70px; }
+.startup-plan-subject { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.startup-plan-status { font-size: 10px; padding: 1px 6px; border: 1px solid var(--panel-border); white-space: nowrap; }
+.startup-plan-overdue { color: var(--error); }
+.startup-plan-overdue .startup-plan-date { color: var(--error); font-weight: 600; }
+.modal-divider {
+ text-align: center;
+ color: var(--muted);
+ font-size: 12px;
+ margin: 8px 0;
+}
+
+/* === Plan page === */
+.plan-page-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 280px;
+ gap: 16px;
+ align-items: start;
+ width: 100%;
+ min-width: 0;
+}
+.plan-list-panel { padding: 0; background: transparent; border: none; box-shadow: none; min-width: 0; }
+.plan-cal-panel {
+ position: sticky; top: 16px;
+ background: var(--panel); border: 1px solid var(--panel-border);
+ border-radius: var(--radius); overflow: hidden; padding: 16px;
+}
+
+.plan-cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
+.plan-cal-title { font-size: 13px; font-weight: 600; color: var(--text); text-align: center; flex: 1; }
+
+.plan-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
+.plan-cal-dow {
+ font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
+ color: var(--muted); text-align: center; padding: 4px 0 6px;
+}
+.plan-cal-day {
+ display: flex; flex-direction: column; align-items: center;
+ padding: 5px 2px 4px; border-radius: 7px; cursor: default;
+ transition: background 0.12s; min-height: 36px;
+}
+.plan-cal-has { cursor: pointer; }
+.plan-cal-has:hover { background: var(--bg-alt); }
+.plan-cal-num { font-size: 12px; font-weight: 500; color: var(--text); line-height: 1; }
+.plan-cal-other .plan-cal-num { color: var(--muted); opacity: 0.4; }
+.plan-cal-today .plan-cal-num {
+ background: var(--accent); color: var(--accent-contrast);
+ border-radius: 50%; width: 20px; height: 20px;
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 700;
+}
+.plan-cal-selected { background: var(--bg-alt); outline: 1.5px solid var(--accent); outline-offset: -1px; }
+.plan-cal-dots { display: flex; gap: 2px; margin-top: 3px; flex-wrap: wrap; justify-content: center; }
+.plan-cal-dot {
+ width: 5px; height: 5px; border-radius: 50%;
+ background: var(--muted); flex-shrink: 0;
+}
+.plan-cal-dot.plan-dot-done { background: var(--success); }
+.plan-cal-dot.plan-dot-queue { background: var(--accent); }
+.plan-cal-dot.plan-dot-wip { background: #e09020; }
+
+.plan-state { padding: 32px 0; color: var(--muted); font-size: 14px; }
+.plan-state-error { color: var(--error); }
+.plan-loaded-at { font-size: 12px; color: var(--muted); }
+.plan-warnings { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
+.plan-warning { font-size: 12px; color: #e09020; background: color-mix(in srgb, #e09020 8%, transparent); border: 1px solid color-mix(in srgb, #e09020 25%, transparent); border-radius: var(--radius); padding: 6px 10px; }
+
+.plan-periods { display: flex; flex-direction: column; gap: 24px; padding: 4px 0 12px; }
+
+.plan-period { display: flex; flex-direction: column; gap: 0; }
+.plan-period-label {
+ font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
+ color: var(--muted); padding: 0 4px 8px;
+}
+.plan-period-urgent .plan-period-label { color: var(--error); }
+.plan-period-warn .plan-period-label { color: #e09020; }
+.plan-period-error .plan-period-label { color: var(--error); opacity: 0.7; }
+
+.plan-period .plan-groups { gap: 8px; padding: 0; }
+
+.plan-group { border: 1px solid var(--panel-border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
+.plan-period-urgent .plan-group { border-left: 3px solid var(--error); }
+.plan-period-warn .plan-group { border-left: 3px solid #e09020; }
+.plan-period-error .plan-group { opacity: 0.55; }
+.plan-group-selected { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 1px; }
+
+.plan-day-header {
+ display: flex; align-items: center; gap: 12px;
+ padding: 10px 20px 9px;
+ border-bottom: 1px solid var(--panel-border);
+ background: var(--bg-alt);
+}
+.plan-day-num { font-size: 22px; font-weight: 700; line-height: 1; color: var(--text); width: 28px; flex-shrink: 0; }
+.plan-day-label { display: flex; flex-direction: column; flex: 1; }
+.plan-day-month { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.2; }
+.plan-day-dow { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-top: 1px; }
+.plan-group-count {
+ font-size: 11px; font-weight: 700; color: var(--accent-contrast);
+ background: var(--accent); border-radius: 20px; padding: 1px 7px; line-height: 1.6; flex-shrink: 0;
+}
+
+.plan-item {
+ display: flex; align-items: center; gap: 12px;
+ padding: 11px 20px;
+ border-bottom: 1px solid var(--panel-border);
+ transition: background 0.12s;
+}
+.plan-item:last-child { border-bottom: none; }
+.plan-item:hover { background: var(--bg-alt); }
+.plan-item:hover .plan-item-btn { opacity: 1; }
+.plan-item-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
+
+.plan-proj-badge {
+ font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
+ color: var(--muted); background: var(--bg-alt); border: 1px solid var(--panel-border);
+ border-radius: 5px; padding: 2px 6px; white-space: nowrap; flex-shrink: 0;
+}
+
+.plan-item-dot {
+ width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
+ background: var(--panel-border);
+}
+.plan-item-dot.plan-dot-done { background: var(--success); }
+.plan-item-dot.plan-dot-queue { background: var(--accent); }
+.plan-item-dot.plan-dot-wip { background: #e09020; }
+
+.plan-item-content { flex: 1; min-width: 0; }
+.plan-item-subject { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); line-height: 1.3; }
+.plan-item-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; min-width: 0; }
+.plan-item-status { font-size: 11px; font-weight: 500; white-space: nowrap; flex-shrink: 0; color: var(--muted); }
+.plan-item-status.plan-dot-done { color: var(--success); }
+.plan-item-status.plan-dot-queue { color: var(--accent); }
+.plan-item-status.plan-dot-wip { color: #e09020; }
+.plan-status-editable { cursor: pointer; border-bottom: 1px dashed currentColor; transition: opacity 0.12s; }
+.plan-status-editable:hover { opacity: 0.7; }
+.plan-status-empty { font-size: 11px; color: var(--muted); cursor: pointer; opacity: 0.45; border-bottom: 1px dashed var(--muted); flex-shrink: 0; }
+.plan-status-empty:hover { opacity: 0.8; }
+.plan-status-select {
+ font-size: 11px; font-weight: 500; color: var(--text);
+ background: var(--input-bg); border: 1px solid var(--accent);
+ border-radius: 5px; padding: 1px 4px; outline: none; cursor: pointer;
+ max-width: 200px; flex-shrink: 0;
+}
+.plan-item-sep { color: var(--panel-border); font-size: 12px; flex-shrink: 0; }
+.plan-item-preheader { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+
+.plan-item-btn {
+ flex-shrink: 0; padding: 6px 12px; font-size: 12px; font-weight: 600;
+ border: 1.5px solid var(--panel-border); border-radius: var(--radius);
+ background: transparent; color: var(--muted); cursor: pointer;
+ transition: border-color 0.15s, color 0.15s; white-space: nowrap;
+ opacity: 0;
+}
+.plan-item-btn:hover { border-color: var(--accent); color: var(--accent); }
+
+.plan-extra-link { font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; opacity: 0.7; flex-shrink: 0; }
+.plan-extra-link:hover { opacity: 1; text-decoration: underline; }
+.plan-item-extra { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
+.plan-extra-tag { font-size: 10px; background: var(--pill); padding: 1px 5px; border-radius: 4px; color: var(--muted); white-space: nowrap; }
+.plan-extra-tag.plan-extra-highlight { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); font-weight: 600; }
+
+/* --- Stats page --- */
+.stats-page { padding: 0 16px 24px; }
+.stats-month { margin-bottom: 24px; }
+.stats-month-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
+.stats-month-label { font-size: 15px; font-weight: 600; text-transform: capitalize; }
+.stats-month-total { font-size: 12px; color: var(--muted); }
+.stats-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.stats-table th { text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid var(--line); }
+.stats-table td { padding: 6px 8px; border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent); }
+.stats-project { font-weight: 500; white-space: nowrap; }
+.stats-letter { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.stats-tag { font-size: 11px; color: var(--muted); }
+.stats-time { white-space: nowrap; font-variant-numeric: tabular-nums; }
+.stats-status { font-size: 11px; color: var(--muted); }
+
+.field-control.inline-col { width: 48px; min-width: 48px; text-align: center; text-transform: uppercase; }
+
+/* --- Image upload field --- */
+.ids-input { max-width: 260px; }
+.image-upload-row { display: flex; align-items: center; gap: 6px; }
+.image-upload-row input[type="text"] { flex: 1; min-width: 0; }
+.image-upload-btn { cursor: pointer; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
+.image-upload-btn svg { display: block; }
+.image-upload-status { font-size: 11px; color: var(--accent); white-space: nowrap; flex-shrink: 0; }
+.image-upload-row.image-drag-over { outline: 2px dashed var(--accent); outline-offset: 2px; background: color-mix(in srgb, var(--accent) 6%, transparent); border-radius: var(--radius); }
+.image-preview-thumb { margin-top: 4px; max-width: 200px; max-height: 120px; border: 1px solid var(--panel-border); border-radius: var(--radius); object-fit: contain; display: block; }
+
+/* Link check panel */
+.feed-diff-list { font-size: 12px; }
+.feed-diff-item { display: flex; gap: 8px; padding: 2px 0; }
+.feed-diff-id { color: var(--accent); font-weight: 600; min-width: 60px; }
+.feed-diff-name { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.link-check-panel { max-height: 200px; overflow-y: auto; font-size: 11px; border-bottom: 1px solid var(--line); padding: 6px 8px; background: var(--panel); }
+.link-check-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
+.link-check-item.link-ok .link-status { color: var(--success); }
+.link-check-item.link-error .link-status { color: var(--error); font-weight: 600; }
+.link-status { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; }
+.link-url { color: var(--text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
+.link-url:hover { text-decoration: underline; }
+.link-redirect { color: var(--muted); font-style: italic; flex-shrink: 0; }
+.link-error-msg { color: var(--error); flex-shrink: 0; }
+
+/* Block dropdown */
+.block-dropdown { position: relative; }
+.block-dropdown-trigger { cursor: pointer; text-align: left; color: var(--muted); width: 100%; }
+.block-dropdown-menu {
+ position: absolute; top: 100%; left: 0; right: 0; z-index: 100;
+ background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-height: 400px; overflow: hidden;
+ display: flex; flex-direction: column; margin-top: 4px;
+}
+.block-dropdown-search { border: none; border-bottom: 1px solid var(--line); border-radius: 0; margin: 0; }
+.block-dropdown-list { overflow-y: auto; flex: 1; }
+.block-dropdown-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--line); transition: background 0.1s; }
+.block-dropdown-item:last-child { border-bottom: none; }
+.block-dropdown-item:hover { background: var(--bg-alt); }
+.block-dropdown-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
+.block-dropdown-item-name { font-weight: 600; font-size: 12px; }
+.block-type-tag { font-size: 9px; padding: 1px 5px; border-radius: 3px; background: var(--pill); color: var(--muted); border: 1px solid var(--pill-border); white-space: nowrap; }
+.block-type-tag.custom { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 20%, transparent); }
+.block-dropdown-preview { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px; color: var(--muted); margin: 0; white-space: pre; overflow: hidden; max-height: 42px; line-height: 14px; }
+
+/* Toast notifications */
+.toast-container { position: fixed; top: 16px; right: 16px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; max-width: 380px; }
+.toast { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; background: var(--accent); color: var(--accent-contrast); border: 1px solid var(--accent); font-size: 13px; line-height: 1.4; white-space: pre-line; box-shadow: 0 4px 20px rgba(0,0,0,0.2); animation: toast-in 0.3s ease; }
+.toast-plan { background: var(--accent); border-color: var(--accent-secondary); border-left: 3px solid var(--accent-secondary); }
+.toast-text { flex: 1; }
+.toast-close { flex-shrink: 0; background: none; border: none; color: inherit; opacity: 0.6; cursor: pointer; font-size: 18px; line-height: 1; padding: 0; margin: -2px 0 0 0; }
+.toast-close:hover { opacity: 1; }
+@keyframes toast-in { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
+
+/* === Gender segments === */
+.block-card.segment-female { border-left: 3px solid #d63031; }
+.block-card.segment-male { border-left: 3px solid #0984e3; }
+.block-card.segment-common { border-left: 3px solid color-mix(in srgb, var(--text) 15%, transparent); }
+
+.segment-toggle { display: flex; gap: 1px; margin-right: 6px; }
+.seg-btn { padding: 1px 5px; font-size: 10px; font-weight: 700; border: 1px solid var(--line); border-radius: 3px; background: transparent; color: var(--text-muted); cursor: pointer; line-height: 1.4; min-width: 20px; text-align: center; }
+.seg-btn:hover { background: var(--hover); }
+.seg-btn.active { background: var(--bg-secondary); color: var(--text); border-color: var(--text-muted); }
+.seg-btn.seg-f.active { background: #d63031; color: #fff; border-color: #d63031; }
+.seg-btn.seg-m.active { background: #0984e3; color: #fff; border-color: #0984e3; }
+.seg-btn.seg-axis.active { background: #6c5ce7; color: #fff; border-color: #6c5ce7; }
+.seg-btn.seg-cert.active { background: #AAC8C8; color: #130F33; border-color: #AAC8C8; }
+.block-card.segment-certificate { border-left: 3px solid #AAC8C8; }
+
+.gender-controls { display: flex; align-items: center; gap: 4px; }
+.sidebar-gender-controls { padding: 2px 0 6px; }
+.gender-btn { padding: 3px 10px; font-size: 12px; font-weight: 700; border-radius: 0; }
+.gender-btn.active { opacity: 1; }
+.gender-btn:not(.active) { opacity: 0.4; }
+.gender-flip-btn { padding: 3px 8px; font-size: 14px; }
+
+/* === ID Pool === */
+.id-pool-panel { padding: 8px; border-bottom: 1px solid var(--line); }
+.id-pool-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.id-pool-title { font-weight: 600; font-size: 12px; }
+.id-pool-count { font-size: 11px; color: var(--text-muted); }
+.id-pool-actions { display: flex; gap: 4px; }
+.id-pool-textarea { width: 100%; min-height: 50px; font-size: 11px; resize: vertical; }
+.id-pool-chips { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 6px; }
+.id-chip { display: inline-block; padding: 2px 6px; font-size: 10px; border-radius: 3px; background: var(--bg-secondary); border: 1px solid var(--line); font-family: monospace; }
+.id-chip.used { background: var(--accent); color: #fff; border-color: var(--accent); opacity: 0.7; }
+
+/* === Image auto-numbering === */
+.image-auto-num { font-size: 10px; color: var(--accent); font-weight: 600; margin-left: 4px; }
+
+
+/* === Сертификат === */
+.cert-toggle { padding: 4px 0 2px; }
+.cert-toggle-label { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; cursor: pointer; }
+.cert-settings-add { display: flex; gap: 6px; margin-bottom: 12px; }
+.cert-block-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.cert-block-item { border: 1px solid var(--panel-border); border-radius: var(--radius); overflow: hidden; }
+.cert-block-item.qb-drag-over { border-color: var(--accent); }
+.cert-block-item.qb-dragging { opacity: 0.4; }
+.cert-block-header { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: var(--bg-alt); }
+.cert-block-name { flex: 1; font-size: 12px; font-weight: 600; }
+.cert-block-fields { padding: 10px; display: flex; flex-direction: column; gap: 6px; }
+.cert-field-label { font-size: 11px; color: var(--muted); margin-bottom: -2px; }
+
+/* === Cert editor banner === */
+.cert-editor-banner { background: #AAC8C8; color: #130F33; padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-radius: var(--radius); margin: 0 0 12px 0; font-size: 13px; font-weight: 700; width: 100%; }
+.cert-editor-actions { display: flex; gap: 6px; }
+.cert-block-summary { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 10px; }
+.cert-block-chip { background: var(--bg-alt); border: 1px solid var(--panel-border); border-radius: 4px; padding: 3px 8px; font-size: 12px; }
diff --git a/z51-pug-builder/src/assets/svelte.svg b/z51-pug-builder/src/assets/svelte.svg
new file mode 100644
index 0000000..c5e0848
--- /dev/null
+++ b/z51-pug-builder/src/assets/svelte.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/back.svg b/z51-pug-builder/src/icons/back.svg
new file mode 100644
index 0000000..b13a9e4
--- /dev/null
+++ b/z51-pug-builder/src/icons/back.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/clear.svg b/z51-pug-builder/src/icons/clear.svg
new file mode 100644
index 0000000..ee99ea1
--- /dev/null
+++ b/z51-pug-builder/src/icons/clear.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/files.svg b/z51-pug-builder/src/icons/files.svg
new file mode 100644
index 0000000..1c9d561
--- /dev/null
+++ b/z51-pug-builder/src/icons/files.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/newmail.svg b/z51-pug-builder/src/icons/newmail.svg
new file mode 100644
index 0000000..4876031
--- /dev/null
+++ b/z51-pug-builder/src/icons/newmail.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/plus.svg b/z51-pug-builder/src/icons/plus.svg
new file mode 100644
index 0000000..fc03b43
--- /dev/null
+++ b/z51-pug-builder/src/icons/plus.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/preset.svg b/z51-pug-builder/src/icons/preset.svg
new file mode 100644
index 0000000..c64920a
--- /dev/null
+++ b/z51-pug-builder/src/icons/preset.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/pug.svg b/z51-pug-builder/src/icons/pug.svg
new file mode 100644
index 0000000..597f836
--- /dev/null
+++ b/z51-pug-builder/src/icons/pug.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/save.svg b/z51-pug-builder/src/icons/save.svg
new file mode 100644
index 0000000..b8af2a5
--- /dev/null
+++ b/z51-pug-builder/src/icons/save.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/settings.svg b/z51-pug-builder/src/icons/settings.svg
new file mode 100644
index 0000000..8cc94d0
--- /dev/null
+++ b/z51-pug-builder/src/icons/settings.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/spacer.svg b/z51-pug-builder/src/icons/spacer.svg
new file mode 100644
index 0000000..6db3c3e
--- /dev/null
+++ b/z51-pug-builder/src/icons/spacer.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/icons/stack.svg b/z51-pug-builder/src/icons/stack.svg
new file mode 100644
index 0000000..55ca7cb
--- /dev/null
+++ b/z51-pug-builder/src/icons/stack.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/z51-pug-builder/src/lib/Counter.svelte b/z51-pug-builder/src/lib/Counter.svelte
new file mode 100644
index 0000000..770c922
--- /dev/null
+++ b/z51-pug-builder/src/lib/Counter.svelte
@@ -0,0 +1,10 @@
+
+
+
+ count is {count}
+
diff --git a/z51-pug-builder/src/lib/api.js b/z51-pug-builder/src/lib/api.js
new file mode 100644
index 0000000..6cbc0f5
--- /dev/null
+++ b/z51-pug-builder/src/lib/api.js
@@ -0,0 +1,212 @@
+const API_BASE = '/api'
+
+export async function apiRequest(path, options = {}) {
+ const res = await fetch(`${API_BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error(data?.details || data?.error || 'API error')
+ }
+ return data
+}
+
+// Single-project: always vipavenue
+export const PROJECT_NAME = 'vipavenue'
+
+export async function apiGetProject(name) {
+ return apiRequest(`/project/${encodeURIComponent(name)}`)
+}
+
+export async function apiSaveBlock(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/block`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiSaveSettings(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/settings`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiSaveLetters(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/letters`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiGetLetter(name, id) {
+ return apiRequest(`/project/${encodeURIComponent(name)}/letter/${encodeURIComponent(id)}`)
+}
+
+export async function apiSaveLetter(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/letter`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiDeleteLetter(name, id) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/letter/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ })
+}
+
+export async function apiGetLetterHistory(name, id) {
+ return apiRequest(`/project/${encodeURIComponent(name)}/letter/${encodeURIComponent(id)}/history`)
+}
+
+export async function apiAppendLetterHistory(name, id, snapshot) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/letter/${encodeURIComponent(id)}/history`, {
+ method: 'PUT',
+ body: JSON.stringify({ snapshot }),
+ })
+}
+
+export async function apiSaveDraft(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/draft`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiSavePresets(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/presets`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiSaveNotes(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/notes`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiGetNote(name, id) {
+ return apiRequest(`/project/${encodeURIComponent(name)}/note/${encodeURIComponent(id)}`)
+}
+
+export async function apiSaveNote(name, payload) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/note`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function apiDeleteNote(name, id) {
+ await apiRequest(`/project/${encodeURIComponent(name)}/note/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ })
+}
+
+export async function apiRenderEmail(name, payload) {
+ return apiRequest(`/project/${encodeURIComponent(name)}/render-email`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ })
+}
+
+export function apiGetConfig() { return apiRequest('/config') }
+export function apiSaveConfig(payload) { return apiRequest('/config', { method: 'PUT', body: JSON.stringify(payload) }) }
+// Yonote API
+export function apiGetYonoteStatus() { return apiRequest('/yonote/status') }
+export function apiYonoteListDatabases() { return apiRequest('/yonote/databases') }
+export function apiYonoteGetProperties(databaseId) {
+ return apiRequest(`/yonote/database/${encodeURIComponent(databaseId)}/properties`)
+}
+export function apiYonoteGetRows(databaseId) {
+ return apiRequest(`/yonote/database/${encodeURIComponent(databaseId)}/rows`)
+}
+export function apiYonoteUpdateRow(rowId, values) {
+ return apiRequest('/yonote/row/update', { method: 'POST', body: JSON.stringify({ rowId, values }) })
+}
+export function apiUploadImage(imageData, fileName, projectName) {
+ return apiRequest('/upload-image', { method: 'POST', body: JSON.stringify({ imageData, fileName, projectName }) })
+}
+export function apiFeedRefresh(projectName) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/feed-refresh`, { method: 'POST' })
+}
+export function apiFeedLookup(projectName, ids) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/feed-lookup`, {
+ method: 'POST', body: JSON.stringify({ ids }),
+ })
+}
+export function apiFeedSuggest(projectName, productId, excludeIds = [], search = '') {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/feed-suggest`, {
+ method: 'POST', body: JSON.stringify({ productId, excludeIds, search }),
+ })
+}
+export function apiCheckLinks(urls) {
+ return apiRequest('/check-links', { method: 'POST', body: JSON.stringify({ urls }) })
+}
+export function apiFtpTest(projectName) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/ftp/test`, { method: 'POST' })
+}
+export function apiFtpUpload(projectName, { imageData, fileName, folder }) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/ftp/upload`, {
+ method: 'POST', body: JSON.stringify({ imageData, fileName, folder }),
+ })
+}
+export function apiFtpList(projectName, folder) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/ftp/list`, {
+ method: 'POST', body: JSON.stringify({ folder }),
+ })
+}
+export function apiFtpDelete(projectName, { folder, fileName }) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/ftp/delete`, {
+ method: 'POST', body: JSON.stringify({ folder, fileName }),
+ })
+}
+export function apiGetStats(projectName) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/stats`)
+}
+export function apiSaveStatEntry(projectName, entry) {
+ return apiRequest(`/project/${encodeURIComponent(projectName)}/stats`, {
+ method: 'POST', body: JSON.stringify({ entry }),
+ })
+}
+export function apiGetAllStats() {
+ return apiRequest('/stats')
+}
+// Auth
+export function apiLogin(login, password) {
+ return apiRequest('/auth/login', { method: 'POST', body: JSON.stringify({ login, password }) })
+}
+export function apiLogout() {
+ return apiRequest('/auth/logout', { method: 'POST' })
+}
+export function apiGetMe() {
+ return fetch('/api/auth/me', { headers: { 'Content-Type': 'application/json' } })
+ .then(r => r.ok ? r.json() : null)
+ .catch(() => null)
+}
+export function apiSavePreferences(prefs) {
+ return apiRequest('/auth/preferences', { method: 'PUT', body: JSON.stringify(prefs) })
+}
+// Admin
+export function apiAdminGetUsers() {
+ return apiRequest('/admin/users')
+}
+export function apiAdminCreateUser(data) {
+ return apiRequest('/admin/users', { method: 'POST', body: JSON.stringify(data) })
+}
+export function apiAdminUpdateUser(id, data) {
+ return apiRequest(`/admin/users/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) })
+}
+export function apiAdminDeleteUser(id) {
+ return apiRequest(`/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE' })
+}
+
+export function apiReadPartsFile(filePath) {
+ return apiRequest(`/parts-file-read?path=${encodeURIComponent(filePath)}`)
+}
+export function apiWritePartsFile(filePath, content) {
+ return apiRequest('/parts-file-write', { method: 'POST', body: JSON.stringify({ path: filePath, content }) })
+}
diff --git a/z51-pug-builder/src/lib/parsing.js b/z51-pug-builder/src/lib/parsing.js
new file mode 100644
index 0000000..3a5635f
--- /dev/null
+++ b/z51-pug-builder/src/lib/parsing.js
@@ -0,0 +1,687 @@
+import { normalizeNewlines, escapeRegExp, unquoteValue } from './utils.js'
+
+export function isExtraTextBlock(name) {
+ return name === 'Доп. текст'
+}
+
+export function isListBlock(name) {
+ return name?.toLowerCase().includes('список')
+}
+
+export function isProductBlock(content) {
+ return normalizeNewlines(content).split('\n').some((line) => line.trim().startsWith('+products'))
+}
+
+export function trimTrailingEmptyLines(lines) {
+ let end = lines.length
+ while (end > 0 && lines[end - 1].trim() === '') end -= 1
+ return lines.slice(0, end)
+}
+
+export function parseBlocks(text) {
+ const lines = normalizeNewlines(text).split('\n')
+ const blocks = []
+ let current = null
+
+ const pushCurrent = () => {
+ if (!current) return
+ const trimmed = trimTrailingEmptyLines(current.lines)
+ blocks.push({
+ name: current.name,
+ content: trimmed.join('\n').trimEnd(),
+ })
+ }
+
+ for (const line of lines) {
+ const isTopLevelComment = line.startsWith('//')
+ if (isTopLevelComment) {
+ pushCurrent()
+ current = { name: line.replace(/^\/\/+\s?/, '').trim(), lines: [] }
+ continue
+ }
+ if (!current) continue
+ current.lines.push(line)
+ }
+
+ pushCurrent()
+ return blocks.filter((block) => block.name && !isExtraTextBlock(block.name))
+}
+
+export function getMixinName(line) {
+ const match = line.trim().match(/^\+([a-zA-Z0-9_]+)/)
+ return match ? match[1] : ''
+}
+
+export function getDefaultMixinFieldLabel(type) {
+ if (type === 'mixin-href') return 'Ссылка'
+ if (type === 'mixin-ids') return 'ID товаров'
+ if (type === 'mixin-opts') return 'Опции'
+ return 'Текст'
+}
+
+export function normalizeMixinRules(rules) {
+ if (!Array.isArray(rules)) return []
+ return rules
+ .map((rule) => ({
+ mixin: String(rule?.mixin || '').trim(),
+ argIndex: Math.max(0, Number(rule?.argIndex) || 0),
+ type: String(rule?.type || 'mixin-text'),
+ label: String(rule?.label || '').trim(),
+ }))
+ .filter((rule) => rule.mixin)
+}
+
+export function getMixinRulesForLine(line, mixinRules) {
+ const mixinName = getMixinName(line)
+ if (!mixinName) return []
+ return normalizeMixinRules(mixinRules || []).filter((rule) => rule.mixin === mixinName)
+}
+
+export function isLikelyUiTextValue(value) {
+ const v = String(value ?? '').trim()
+ if (!v) return false
+ if (/^(https?:\/\/|\/)/i.test(v)) return false
+ if (/^\d+(?:[.,]\d+)?%?$/.test(v)) return false
+ if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v)) return false
+ if (/^(left|right|center|top|bottom|middle|cover|contain|auto|true|false|none)$/i.test(v)) return false
+ return /[A-Za-zА-Яа-яЁё]/.test(v)
+}
+
+export function guessAutoMixinField(rawArg, argIndex) {
+ const raw = String(rawArg || '').trim()
+ if (!raw) return { type: 'mixin-text', label: 'Текст' }
+ const first = raw[0]
+ const last = raw[raw.length - 1]
+ const unquoted =
+ (first === '"' && last === '"') || (first === "'" && last === "'")
+ ? raw.slice(1, -1)
+ : raw
+
+ if (/^\d+(?:\s*,\s*\d+)+$/.test(unquoted)) {
+ return { type: 'mixin-ids', label: 'ID товаров' }
+ }
+ if (raw.startsWith('{') && raw.endsWith('}')) {
+ return { type: 'mixin-opts', label: 'Опции' }
+ }
+ if (/^(https?:\/\/|\/)/i.test(unquoted)) {
+ if (/\.(png|jpe?g|gif|webp|svg)(\?|#|$)/i.test(unquoted)) {
+ return { type: 'mixin-href', label: 'Картинка' }
+ }
+ return { type: 'mixin-href', label: argIndex === 0 ? 'Ссылка' : `Ссылка ${argIndex + 1}` }
+ }
+ return { type: 'mixin-text', label: 'Текст' }
+}
+
+export function guessObjectFieldByKey(key, value) {
+ const name = String(key || '').toLowerCase()
+ const val = String(value || '')
+ if (name.includes('image') || name.includes('img') || name.includes('src') || name.includes('banner')) {
+ return { type: 'mixin-href', label: 'Картинка' }
+ }
+ if (name.includes('href') || name.includes('url') || name.includes('link')) {
+ return { type: 'mixin-href', label: 'Ссылка' }
+ }
+ if (/^\d+(?:\s*,\s*\d+)+$/.test(val)) {
+ return { type: 'mixin-ids', label: 'ID товаров' }
+ }
+ return { type: 'mixin-text', label: key }
+}
+
+export function shouldExposeObjectField(key, value, guessedType) {
+ const k = String(key ?? '').toLowerCase()
+ const v = String(value ?? '').trim()
+ if (!k) return false
+ if (guessedType === 'mixin-href' || guessedType === 'mixin-ids') return true
+ if (/text|title|header|caption|description|desc|label|button|btn|cta|name/.test(k)) return true
+ if (/^(src|img|image|icon|logo|photo|pic|picture|poster|thumb|preview|banner|cover)$/.test(k)) return true
+ if (/width|height|size|align|valign|padding|margin|radius|border|line|space|offset|top|left|right|bottom|color|bg|background|show|hide|count|qty|sort|order|mode|type|target/.test(k)) {
+ return false
+ }
+ return isLikelyUiTextValue(v)
+}
+
+export function shouldExposeAutoMixinArg(line, rawArg, guessedType, argIndex) {
+ if (guessedType === 'mixin-href' || guessedType === 'mixin-ids') return true
+ if (guessedType === 'mixin-opts') return false
+ const raw = String(rawArg ?? '').trim()
+ const unquoted = unquoteValue(raw)
+ // Allow empty quoted strings — user may have cleared the field
+ if (!unquoted && (raw === '""' || raw === "''")) {
+ const mixin = getMixinName(line).toLowerCase()
+ if (/button|title|text|caption|banner|image|img|link|href|url|header/.test(mixin)) return true
+ }
+ if (!isLikelyUiTextValue(unquoted)) return false
+ const mixin = getMixinName(line).toLowerCase()
+ if (/button|title|text|caption|banner|image|img|link|href|url|header/.test(mixin)) return true
+ return argIndex === 0 && unquoted.length > 5
+}
+
+export function parseMixinObjectArg(rawArg) {
+ const raw = String(rawArg || '').trim()
+ if (!(raw.startsWith('{') && raw.endsWith('}'))) return []
+ const body = raw.slice(1, -1)
+ const result = []
+ const regex = /([a-zA-Z0-9_]+)\s*:\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^,}]+)/g
+ let match
+ while ((match = regex.exec(body))) {
+ const key = match[1]
+ const rawValue = (match[2] || '').trim()
+ const value = unquoteValue(rawValue)
+ result.push({ key, rawValue, value })
+ }
+ return result
+}
+
+export function parseQuotedArgs(line) {
+ const args = []
+ const regex = /"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'/g
+ let match
+ while ((match = regex.exec(line))) {
+ const value = match[1] ?? match[2] ?? ''
+ const valueIndex = 1
+ const start = match.index + valueIndex
+ const end = start + value.length
+ args.push({ value, start, end })
+ }
+ return args
+}
+
+export function findMixinArgRange(line, argIndex) {
+ const openIdx = line.indexOf('(')
+ if (openIdx === -1) return null
+ let i = openIdx + 1
+ let depthBrace = 0
+ let depthBracket = 0
+ let depthParen = 0
+ let inQuote = ''
+ const ranges = []
+ let argStart = i
+ for (; i < line.length; i += 1) {
+ const ch = line[i]
+ if (inQuote) {
+ if (ch === inQuote && line[i - 1] !== '\\') inQuote = ''
+ continue
+ }
+ if (ch === '"' || ch === "'") {
+ inQuote = ch
+ continue
+ }
+ if (ch === '{') depthBrace += 1
+ if (ch === '}') depthBrace = Math.max(0, depthBrace - 1)
+ if (ch === '[') depthBracket += 1
+ if (ch === ']') depthBracket = Math.max(0, depthBracket - 1)
+ if (ch === '(') depthParen += 1
+ if (ch === ')') depthParen = Math.max(0, depthParen - 1)
+ if (ch === ')' && depthBrace === 0 && depthBracket === 0 && depthParen === 0) {
+ ranges.push([argStart, i])
+ break
+ }
+ if (ch === ',' && depthBrace === 0 && depthBracket === 0 && depthParen === 0) {
+ ranges.push([argStart, i])
+ argStart = i + 1
+ }
+ }
+ if (!ranges[argIndex]) return null
+ let [start, end] = ranges[argIndex]
+ while (start < end && /\s/.test(line[start])) start += 1
+ while (end > start && /\s/.test(line[end - 1])) end -= 1
+ return { start, end }
+}
+
+export function getMixinArgCount(line) {
+ let count = 0
+ while (count < 12 && findMixinArgRange(line, count)) count += 1
+ return count
+}
+
+export function extractMixinArgValue(line, argIndex) {
+ const range = findMixinArgRange(line, argIndex)
+ if (!range) return ''
+ const raw = line.slice(range.start, range.end).trim()
+ if (!raw) return ''
+ const first = raw[0]
+ const last = raw[raw.length - 1]
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
+ return raw.slice(1, -1)
+ }
+ return raw
+}
+
+export function replaceMixinArgQuoted(line, argIndex, newValue) {
+ const range = findMixinArgRange(line, argIndex)
+ if (!range) return line
+ return line.slice(0, range.start) + `"${newValue}"` + line.slice(range.end)
+}
+
+export function replaceMixinArgRaw(line, argIndex, newValue) {
+ const range = findMixinArgRange(line, argIndex)
+ if (!range) return line
+ return line.slice(0, range.start) + newValue + line.slice(range.end)
+}
+
+export function replaceObjectPropInArg(rawArg, key, newValue) {
+ const keyPattern = new RegExp(
+ `(${escapeRegExp(key)}\\s*:\\s*)("(?:\\\\.|[^"])*"|'(?:\\\\.|[^'])*'|[^,}]+)`
+ )
+ const match = rawArg.match(keyPattern)
+ if (!match) return rawArg
+ const oldToken = String(match[2] || '').trim()
+ const quote = oldToken.startsWith("'") && oldToken.endsWith("'") ? "'" : '"'
+ const escaped = String(newValue ?? '')
+ .replace(/\\/g, '\\\\')
+ .replace(new RegExp(escapeRegExp(quote), 'g'), `\\${quote}`)
+ return rawArg.replace(keyPattern, `$1${quote}${escaped}${quote}`)
+}
+
+export function splitTextField(line) {
+ const leading = line.match(/^\s*/)?.[0].length ?? 0
+ let depth = 0
+ for (let i = leading; i < line.length; i += 1) {
+ const ch = line[i]
+ if (ch === '(') depth += 1
+ if (ch === ')') depth = Math.max(0, depth - 1)
+ if (ch === ' ' && depth === 0) {
+ const text = line.slice(i + 1)
+ return {
+ start: i + 1,
+ end: i + 1 + text.length,
+ value: text,
+ }
+ }
+ }
+ return null
+}
+
+export function getParagraphMeta(line) {
+ if (!line.trim()) return null
+ const trimmed = line.trim()
+ if (trimmed.startsWith('//') || trimmed.startsWith('+') || trimmed.startsWith('-')) return null
+ const meta = splitTextField(line)
+ if (!meta) return null
+ const tagMatch = trimmed.match(/^([a-zA-Z0-9-]+)/)
+ const tag = tagMatch ? tagMatch[1].toLowerCase() : ''
+ const structuralTags = ['tr', 'td', 'table', 'tbody', 'thead', 'tfoot']
+ if (structuralTags.includes(tag)) return null
+ return meta
+}
+
+export function getListItemRanges(lines) {
+ const ranges = []
+ for (let i = 0; i < lines.length; i += 1) {
+ const line = lines[i]
+ if (line.trim() !== 'tr') continue
+ const indent = line.match(/^\s*/)?.[0].length ?? 0
+ if (indent === 0) continue
+ let nextNonEmpty = ''
+ for (let j = i + 1; j < Math.min(lines.length, i + 6); j += 1) {
+ if (lines[j].trim()) {
+ nextNonEmpty = lines[j].trim()
+ break
+ }
+ }
+ if (!nextNonEmpty.startsWith('+tdFixed')) continue
+ const slice = lines.slice(i, i + 8).join('\n')
+ if (!slice.includes('span.text')) continue
+ ranges.push({ start: i })
+ }
+ if (!ranges.length) return []
+ for (let i = 0; i < ranges.length; i += 1) {
+ const nextStart = ranges[i + 1]?.start ?? lines.length
+ ranges[i].end = nextStart - 1
+ }
+ return ranges
+}
+
+export function getListItemTextLine(lines, range) {
+ for (let i = range.start; i <= range.end; i += 1) {
+ const line = lines[i]
+ if (!line) continue
+ if (!line.includes('span.text')) continue
+ return i
+ }
+ return -1
+}
+
+export function buildBaseSchema(content, blockName, mixinRules = []) {
+ if (blockName && blockName.toLowerCase().includes('3 товара в ряд')) {
+ return [
+ {
+ type: 'raw',
+ label: 'Код блока',
+ },
+ ]
+ }
+ const lines = normalizeNewlines(content).split('\n')
+ const fields = []
+ let paragraphIndex = 0
+
+ if (isListBlock(blockName)) {
+ const ranges = getListItemRanges(lines)
+ if (ranges.length === 0) {
+ // Name says "список" but no list-item structure found — fall through to generic parsing
+ } else {
+ ranges.forEach((range, index) => {
+ const lineIndex = getListItemTextLine(lines, range)
+ if (lineIndex === -1) return
+ fields.push({
+ type: 'list-item',
+ label: 'Пункт',
+ lineIndex,
+ itemIndex: index,
+ removable: index > 0,
+ })
+ })
+ return fields
+ }
+ }
+
+ lines.forEach((line, lineIndex) => {
+ if (!line.trim()) return
+ const trimmed = line.trim()
+ const customMixinRules = getMixinRulesForLine(trimmed, mixinRules)
+ if (customMixinRules.length) {
+ customMixinRules.forEach((rule) => {
+ const argIndex = Math.max(0, Number(rule.argIndex) || 0)
+ if (!findMixinArgRange(line, argIndex)) return
+ fields.push({
+ type: rule.type,
+ label: rule.label || getDefaultMixinFieldLabel(rule.type),
+ lineIndex,
+ argIndex,
+ })
+ })
+ return
+ }
+ if (trimmed.startsWith('+buttonRounded')) {
+ const args = parseQuotedArgs(line)
+ if (args[0]) {
+ fields.push({
+ type: 'mixin-text',
+ label: 'Текст кнопки',
+ lineIndex,
+ argIndex: 0,
+ })
+ }
+ if (args[1]) {
+ fields.push({
+ type: 'mixin-href',
+ label: 'Ссылка кнопки',
+ lineIndex,
+ argIndex: 1,
+ })
+ }
+ return
+ }
+ if (trimmed.startsWith('+products') || trimmed.startsWith('+product')) {
+ const argCount = getMixinArgCount(line)
+ for (let ai = 0; ai < argCount; ai++) {
+ const range = findMixinArgRange(line, ai)
+ if (!range) continue
+ const rawArg = line.slice(range.start, range.end)
+ const guessed = guessAutoMixinField(rawArg, ai)
+ if (guessed.type === 'mixin-ids') {
+ fields.push({ type: 'mixin-ids', label: 'ID товаров', lineIndex, argIndex: ai })
+ } else if (guessed.type === 'mixin-href') {
+ fields.push({ type: 'mixin-href', label: guessed.label, lineIndex, argIndex: ai })
+ }
+ // skip mixin-opts and mixin-text for product mixins
+ }
+ return
+ }
+ if (trimmed.startsWith('+')) {
+ const argCount = getMixinArgCount(line)
+ for (let argIndex = 0; argIndex < argCount; argIndex += 1) {
+ const range = findMixinArgRange(line, argIndex)
+ if (!range) continue
+ const rawArg = line.slice(range.start, range.end)
+ const objectFields = parseMixinObjectArg(rawArg)
+ if (objectFields.length) {
+ objectFields.forEach((item) => {
+ const guessedByKey = guessObjectFieldByKey(item.key, item.value)
+ if (!shouldExposeObjectField(item.key, item.value, guessedByKey.type)) return
+ fields.push({
+ type: guessedByKey.type,
+ label: guessedByKey.label,
+ lineIndex,
+ argIndex,
+ objectKey: item.key,
+ })
+ })
+ continue
+ }
+ let guessed = guessAutoMixinField(rawArg, argIndex)
+ // For image/background mixins, first quoted arg is likely an image URL
+ if (argIndex === 0 && guessed.type === 'mixin-text') {
+ const mn = getMixinName(line).toLowerCase()
+ if (/image|img|background|banner|poster|cover|photo/.test(mn)) {
+ guessed = { type: 'mixin-href', label: 'Картинка' }
+ }
+ }
+ if (!shouldExposeAutoMixinArg(line, rawArg, guessed.type, argIndex)) continue
+ fields.push({
+ type: guessed.type,
+ label: guessed.label,
+ lineIndex,
+ argIndex,
+ })
+ }
+ return
+ }
+ if (trimmed.startsWith('//') || trimmed.startsWith('+') || trimmed.startsWith('-')) return
+
+ const textField = splitTextField(line)
+ if (textField) {
+ if (blockName === 'Текст') {
+ const paragraphMeta = getParagraphMeta(line)
+ if (!paragraphMeta) return
+ paragraphIndex += 1
+ fields.push({
+ type: 'text',
+ label: 'Текст',
+ lineIndex,
+ paragraphIndex,
+ removable: paragraphIndex > 1,
+ })
+ } else if (textField.value.trim() !== '' || getParagraphMeta(line)) {
+ fields.push({
+ type: 'text',
+ label: 'Текст',
+ lineIndex,
+ })
+ }
+ }
+
+ const linkRegex = /(href|src)=("([^"]*)"|'([^']*)')/g
+ let match
+ let occurrence = 0
+ while ((match = linkRegex.exec(line))) {
+ fields.push({
+ type: match[1],
+ label: match[1] === 'href' ? 'Ссылка' : 'Картинка',
+ lineIndex,
+ occurrence,
+ })
+ occurrence += 1
+ }
+ })
+
+ return fields
+}
+
+export function guessSectionLabel(lines) {
+ for (const line of lines) {
+ const t = line.trim()
+ if (t.startsWith('//---')) return t.replace(/^\/\/---\s*/, '').trim() || 'Секция'
+ if (/\+button/i.test(t)) return 'Кнопка'
+ if (/\+backgroundImage/i.test(t) || /img\(/.test(t)) return 'Картинка'
+ if (/\+product/i.test(t)) return 'Товары'
+ if (/span\.\S*header/i.test(t)) return 'Заголовок'
+ if (/span\.\S*text/i.test(t)) return 'Текст'
+ if (/^a\(href/i.test(t)) return 'Ссылка'
+ }
+ return 'Секция'
+}
+
+function parseAtIndent(lines, targetIndent) {
+ const prefix = []
+ const columns = []
+ const separators = []
+ const suffix = []
+
+ let state = 'prefix'
+ let currentSections = []
+ let currentGroup = null
+ let gapLines = []
+
+ const isTarget = (stripped, indent) =>
+ indent === targetIndent && (/^tr\b/.test(stripped) || /^\+spacerLine\(/.test(stripped) || /^\/\/---/.test(stripped))
+
+ const finishGroup = () => {
+ if (currentGroup) { currentSections.push(currentGroup); currentGroup = null }
+ }
+ const finishColumn = () => {
+ finishGroup()
+ if (currentSections.length > 0) {
+ columns.push({ sections: currentSections })
+ currentSections = []
+ }
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]
+ const t = line.trimEnd()
+ const stripped = t.trimStart()
+ const indent = t.length - stripped.length
+
+ if (state === 'prefix') {
+ if (isTarget(stripped, indent)) {
+ state = 'sections'
+ } else {
+ prefix.push(line)
+ continue
+ }
+ }
+
+ if (state === 'gap') {
+ if (isTarget(stripped, indent)) {
+ separators.push(gapLines)
+ gapLines = []
+ currentSections = []
+ state = 'sections'
+ } else {
+ gapLines.push(line)
+ continue
+ }
+ }
+
+ // state === 'sections'
+ if (indent === targetIndent) {
+ if (/^\+spacerLine\(/.test(stripped)) {
+ finishGroup()
+ currentSections.push({ type: 'spacer', lines: [line], startLine: i })
+ } else if (/^tr\b/.test(stripped) || /^\/\/---/.test(stripped)) {
+ finishGroup()
+ currentGroup = { type: 'section', lines: [line], startLine: i }
+ } else {
+ if (currentGroup) currentGroup.lines.push(line)
+ }
+ } else if (indent > targetIndent && currentGroup) {
+ currentGroup.lines.push(line)
+ } else if (!stripped) {
+ if (currentGroup) currentGroup.lines.push(line)
+ } else {
+ finishColumn()
+ state = 'gap'
+ gapLines = [line]
+ }
+ }
+
+ if (state === 'sections') {
+ finishColumn()
+ } else if (state === 'gap') {
+ suffix.push(...gapLines)
+ }
+
+ if (columns.length === 0) return null
+
+ // Add labels
+ for (const col of columns) {
+ for (const s of col.sections) {
+ if (s.type === 'section') s.label = guessSectionLabel(s.lines)
+ }
+ }
+
+ // First column must have at least 2 content sections
+ const firstContentSecs = columns[0].sections.filter(s => s.type === 'section')
+ if (firstContentSecs.length < 2) return null
+
+ // For multi-column: verify all columns have same number of content sections
+ if (columns.length > 1) {
+ for (let c = 1; c < columns.length; c++) {
+ const n = columns[c].sections.filter(s => s.type === 'section').length
+ if (n !== firstContentSecs.length) return null
+ }
+ }
+
+ // Compute spacer values from first column
+ const spacerItems = columns[0].sections.filter(s => s.type === 'spacer')
+ let topSpacerValue = 40, bottomSpacerValue = 40
+ const extractVal = (s) => { const m = s.lines[0].match(/\+spacerLine\((\d+)/); return m ? parseInt(m[1]) : 20 }
+ if (spacerItems.length >= 1) topSpacerValue = extractVal(spacerItems[0])
+ if (spacerItems.length >= 2) bottomSpacerValue = extractVal(spacerItems[spacerItems.length - 1])
+
+ return { prefix, columns, separators, suffix, targetIndent, topSpacerValue, bottomSpacerValue }
+}
+
+export function parseSections(content) {
+ if (!content) return null
+ const lines = normalizeNewlines(content).split('\n')
+
+ // Collect indent levels where tr and +spacerLine appear
+ const indentCounts = new Map()
+ for (const line of lines) {
+ const t = line.trimEnd()
+ if (!t) continue
+ const indent = t.length - t.trimStart().length
+ const stripped = t.trimStart()
+ if (/^tr\b/.test(stripped) || /^\+spacerLine\(/.test(stripped)) {
+ indentCounts.set(indent, (indentCounts.get(indent) || 0) + 1)
+ }
+ }
+ if (indentCounts.size === 0) return null
+
+ // Try indent levels from shallowest to deepest
+ const sortedIndents = [...indentCounts.entries()]
+ .filter(([, count]) => count >= 3)
+ .sort(([a], [b]) => a - b)
+
+ for (const [targetIndent] of sortedIndents) {
+ const result = parseAtIndent(lines, targetIndent)
+ if (result) return result
+ }
+ return null
+}
+
+function normalizeSpacers(sections, topVal, bottomVal) {
+ const spacers = sections.filter(s => s.type === 'spacer')
+ if (spacers.length < 2) return
+ const setVal = (s, val) => { s.lines[0] = s.lines[0].replace(/\+spacerLine\(\d+/, `+spacerLine(${val}`) }
+ for (const s of spacers) setVal(s, 20)
+ setVal(spacers[0], topVal || 40)
+ setVal(spacers[spacers.length - 1], bottomVal || 40)
+}
+
+export function rebuildContentFromSections(parsed) {
+ for (const col of parsed.columns) {
+ normalizeSpacers(col.sections, parsed.topSpacerValue, parsed.bottomSpacerValue)
+ }
+ const result = [...parsed.prefix]
+ for (let c = 0; c < parsed.columns.length; c++) {
+ result.push(...parsed.columns[c].sections.flatMap(s => s.lines))
+ if (c < parsed.separators.length) {
+ result.push(...parsed.separators[c])
+ }
+ }
+ result.push(...parsed.suffix)
+ return result.join('\n')
+}
diff --git a/z51-pug-builder/src/lib/spellcheck.js b/z51-pug-builder/src/lib/spellcheck.js
new file mode 100644
index 0000000..f71102e
--- /dev/null
+++ b/z51-pug-builder/src/lib/spellcheck.js
@@ -0,0 +1,57 @@
+export function extractTextFromHtml(html) {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(html, 'text/html')
+ doc.querySelectorAll('style, script').forEach((el) => el.remove())
+ doc.body?.querySelectorAll('*').forEach((el) => {
+ el.before(doc.createTextNode(' '))
+ el.after(doc.createTextNode(' '))
+ })
+ return (doc.body?.textContent || '')
+ .replace(/\u00a0/g, ' ')
+ .replace(/[«»„\u201C\u201D\u2018\u2019]/g, ' ')
+ .replace(/\{%[\s\S]*?%\}/g, ' ')
+ .replace(/\{\{[^}]*\}\}|@\{[^}]*\}|\$\{[^}]*\}/g, ' ')
+ .replace(/\s+/g, ' ')
+ .replace(/(?:[а-яёА-ЯЁa-zA-Z]+:\s*){4,}/g, ' ')
+ .replace(/(.{10,60})\1+/g, '$1')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export function injectSpellMarks(html, errors) {
+ if (!errors.length) return html
+ const wordMap = new Map()
+ for (const err of errors) {
+ if (!wordMap.has(err.word)) {
+ let tip
+ if (err.code === 2) {
+ tip = 'Повторяющееся слово'
+ } else if (err.s && err.s.length && err.s[0] !== err.word) {
+ tip = '→ ' + err.s.join(', ')
+ } else {
+ tip = 'Неизвестное слово'
+ }
+ wordMap.set(err.word, tip)
+ }
+ }
+ const unique = [...wordMap.keys()].sort((a, b) => b.length - a.length)
+ const css =
+ ''
+ let result = html.includes('') ? html.replace('', css + '') : css + html
+ result = result.replace(/(<[^>]*>)|([^<]+)/g, (match, tag, text) => {
+ if (tag) return tag
+ if (!text || !text.trim()) return text
+ let replaced = text
+ for (const word of unique) {
+ const tip = wordMap.get(word)
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const titleAttr = tip ? ` title="${tip.replace(/"/g, '"')}"` : ''
+ replaced = replaced.replace(
+ new RegExp(`(?$&`
+ )
+ }
+ return replaced
+ })
+ return result
+}
diff --git a/z51-pug-builder/src/lib/utils.js b/z51-pug-builder/src/lib/utils.js
new file mode 100644
index 0000000..5238d72
--- /dev/null
+++ b/z51-pug-builder/src/lib/utils.js
@@ -0,0 +1,105 @@
+export function downloadFile(content, fileName, mimeType = 'text/plain;charset=utf-8') {
+ const blob = new Blob([content], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = fileName
+ a.click()
+ URL.revokeObjectURL(url)
+}
+
+export function normalizeNewlines(text) {
+ return text.replace(/\r\n/g, '\n')
+}
+
+export function createId() {
+ return typeof crypto !== 'undefined' && crypto.randomUUID
+ ? crypto.randomUUID()
+ : `block-${Date.now()}-${Math.random().toString(16).slice(2)}`
+}
+
+export function formatLetterDate(value) {
+ if (!value) return ''
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return value
+ const day = String(date.getDate()).padStart(2, '0')
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const year = date.getFullYear()
+ return `${day}-${month}-${year}`
+}
+
+export function buildLetterName(title, date) {
+ const formatted = formatLetterDate(date)
+ const trimmed = String(title || '').trim()
+ if (!trimmed) return formatted
+ return `${formatted} ${trimmed}`
+}
+
+export function escapeHtml(value) {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+}
+
+export function visualizeLineBreaks(value) {
+ const escaped = escapeHtml(value)
+ return escaped.replace(/\n/g, '\\n ')
+}
+
+export function escapeRegExp(text) {
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+export function unquoteValue(raw) {
+ const value = String(raw ?? '').trim()
+ const first = value[0]
+ const last = value[value.length - 1]
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
+ return value.slice(1, -1)
+ }
+ return value
+}
+
+export function normalizeHtmlForCopy(html) {
+ return html
+ .replace(/\t+/g, '')
+ .split('\n')
+ .map((line) => line.replace(/\s+$/g, '').replace(/^ +/g, ''))
+ .join('\n')
+ .replace(/ {2,}/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
+export function parseOptionCode(code) {
+ if (!code) return null
+ const parts = code.split(':')
+ if (parts.length < 2) return null
+ const key = parts.shift().trim()
+ const value = parts.join(':').trim()
+ if (!key || !value) return null
+ return { key, value }
+}
+
+export function parseOptionsObject(text) {
+ const map = {}
+ if (!text) return map
+ const regex = /([a-zA-Z0-9_]+)\s*:\s*([^,}]+)/g
+ let match
+ while ((match = regex.exec(text))) {
+ map[match[1]] = match[2].trim()
+ }
+ return map
+}
+
+export function stringifyOptions(map, order = []) {
+ const keys = []
+ order.forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(map, key)) keys.push(key)
+ })
+ Object.keys(map).forEach((key) => {
+ if (!keys.includes(key)) keys.push(key)
+ })
+ return keys.map((key) => `${key}: ${map[key]}`).join(', ')
+}
diff --git a/z51-pug-builder/src/main.js b/z51-pug-builder/src/main.js
new file mode 100644
index 0000000..458c7a8
--- /dev/null
+++ b/z51-pug-builder/src/main.js
@@ -0,0 +1,9 @@
+import { mount } from 'svelte'
+import './app.css'
+import App from './App.svelte'
+
+const app = mount(App, {
+ target: document.getElementById('app'),
+})
+
+export default app
diff --git a/z51-pug-builder/svelte.config.js b/z51-pug-builder/svelte.config.js
new file mode 100644
index 0000000..96b3455
--- /dev/null
+++ b/z51-pug-builder/svelte.config.js
@@ -0,0 +1,8 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
+export default {
+ // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+}
diff --git a/z51-pug-builder/vite.config.js b/z51-pug-builder/vite.config.js
new file mode 100644
index 0000000..2b1f7c9
--- /dev/null
+++ b/z51-pug-builder/vite.config.js
@@ -0,0 +1,1600 @@
+import { defineConfig } from 'vite'
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import { spawnSync } from 'child_process'
+import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'crypto'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { YonoteClient } from '@yonote/js-sdk'
+
+const PROJECT_NAME = 'vipavenue'
+
+const apiPlugin = () => ({
+ name: 'va-file-api',
+ configureServer(server) {
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
+ const dataDir = path.resolve(__dirname, 'data')
+ const emailGenRoot = path.resolve(__dirname, '..', 'email-gen')
+ const emailGenApiUrl = process.env.EMAIL_GEN_API_URL || ''
+ const configFile = path.resolve(dataDir, 'config.json')
+
+ const getYonoteClient = () => {
+ const config = readJson(configFile, {})
+ if (!config.yonote_token) return null
+ return new YonoteClient({
+ token: config.yonote_token,
+ baseUrl: config.yonote_base_url || 'https://app.yonote.ru',
+ })
+ }
+
+ const ensureDir = (dir) => {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+ }
+
+ const readJson = (file, fallback) => {
+ try {
+ if (!fs.existsSync(file)) return fallback
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
+ } catch {
+ return fallback
+ }
+ }
+
+ const writeJson = (file, data) => {
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8')
+ }
+
+ const sanitizeProjectSlug = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
+ const sanitizeFileId = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
+
+ const getProjectDir = (name) => {
+ const dir = path.resolve(dataDir, name)
+ if (!dir.startsWith(dataDir + path.sep)) return null
+ return dir
+ }
+
+ const MAX_BODY_SIZE = 20 * 1024 * 1024
+ class BodyError extends Error { constructor(msg, status) { super(msg); this.status = status } }
+ const readBody = (req) =>
+ new Promise((resolve, reject) => {
+ let size = 0
+ let data = ''
+ req.on('data', (chunk) => {
+ size += chunk.length
+ if (size <= MAX_BODY_SIZE) data += chunk
+ })
+ req.on('end', () => {
+ if (size > MAX_BODY_SIZE) return reject(new BodyError('payload_too_large', 413))
+ try {
+ resolve(data ? JSON.parse(data) : {})
+ } catch {
+ reject(new BodyError('invalid_json', 400))
+ }
+ })
+ })
+
+ // --- Image uploads ---
+ const uploadsDir = path.resolve(dataDir, 'uploads')
+
+ // Render concurrency limiter
+ let activeRenders = 0
+ const MAX_CONCURRENT_RENDERS = 3
+
+ // Feed cache (persists across requests + saved to disk)
+ const feedCache = new Map()
+ const feedPending = new Map()
+ const FEED_CACHE_TTL = 3 * 60 * 60 * 1000
+ const feedCacheFile = path.resolve(dataDir, 'feed-cache.json')
+
+ // Restore feed cache from disk on startup
+ try {
+ if (fs.existsSync(feedCacheFile)) {
+ const saved = JSON.parse(fs.readFileSync(feedCacheFile, 'utf-8'))
+ if (saved.url && saved.ts && saved.products && Date.now() - saved.ts < FEED_CACHE_TTL) {
+ const products = new Map(Object.entries(saved.products))
+ feedCache.set(saved.url, { ts: saved.ts, products })
+ console.log(`[feed] Restored ${products.size} products from cache (age: ${Math.round((Date.now() - saved.ts) / 60000)}m)`)
+ }
+ }
+ } catch {}
+
+ function saveFeedCacheToDisk(feedUrl, ts, products) {
+ try {
+ const obj = {}
+ for (const [k, v] of products) obj[k] = v
+ fs.writeFileSync(feedCacheFile, JSON.stringify({ url: feedUrl, ts, products: obj }), 'utf-8')
+ } catch {}
+ }
+
+ // Pug render cache by hash (LRU, max 30 entries, persisted to disk)
+ const renderCacheFile = path.resolve(dataDir, 'render-cache.json')
+ const renderCache = new Map()
+ const RENDER_CACHE_MAX = 30
+ try {
+ if (fs.existsSync(renderCacheFile)) {
+ const saved = JSON.parse(fs.readFileSync(renderCacheFile, 'utf-8'))
+ for (const [k, v] of Object.entries(saved)) renderCache.set(k, v)
+ }
+ } catch {}
+ function saveRenderCacheToDisk() {
+ const obj = {}
+ for (const [k, v] of renderCache) obj[k] = v
+ fs.promises.writeFile(renderCacheFile, JSON.stringify(obj), 'utf-8').catch(() => {})
+ }
+ function pugHash(slug, pug, gender, genderPaths) {
+ const extra = genderPaths ? JSON.stringify(genderPaths) : ''
+ return createHash('md5').update(slug + '\0' + pug + '\0' + (gender || 'female') + '\0' + extra).digest('hex')
+ }
+ function setRenderCache(key, html) {
+ if (renderCache.size >= RENDER_CACHE_MAX) {
+ const oldest = renderCache.keys().next().value
+ renderCache.delete(oldest)
+ }
+ renderCache.set(key, html)
+ saveRenderCacheToDisk()
+ }
+
+ // --- Auth system ---
+ const systemDir = path.resolve(dataDir, '_system')
+ ensureDir(systemDir)
+ const usersFile = path.resolve(systemDir, 'users.json')
+ const SESSION_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days
+ const sessionsFile = path.resolve(systemDir, 'sessions.json')
+ function loadSessions() {
+ const raw = readJson(sessionsFile, {})
+ const m = new Map(Object.entries(raw))
+ const now = Date.now()
+ for (const [k, v] of m) if (v.expiresAt < now) m.delete(k)
+ return m
+ }
+ function saveSessions(store) {
+ fs.promises.writeFile(sessionsFile, JSON.stringify(Object.fromEntries(store))).catch(() => {})
+ }
+ const sessionsStore = loadSessions()
+
+ function hashPassword(password) {
+ const salt = randomBytes(16).toString('hex')
+ const hash = scryptSync(password, salt, 64).toString('hex')
+ return `${salt}:${hash}`
+ }
+ function verifyPassword(password, stored) {
+ const [salt, hash] = stored.split(':')
+ if (!salt || !hash) return false
+ const test = scryptSync(password, salt, 64).toString('hex')
+ try { return timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex')) } catch { return false }
+ }
+ function getUsers() { return readJson(usersFile, []) }
+ function saveUsers(users) { writeJson(usersFile, users) }
+ function createSession(userId) {
+ const token = randomBytes(32).toString('hex')
+ sessionsStore.set(token, { userId, expiresAt: Date.now() + SESSION_TTL })
+ saveSessions(sessionsStore)
+ return token
+ }
+ function getSession(token) {
+ const s = sessionsStore.get(token)
+ if (!s) return null
+ if (s.expiresAt < Date.now()) { sessionsStore.delete(token); saveSessions(sessionsStore); return null }
+ return s
+ }
+ function getTokenFromReq(req) {
+ const cookie = req.headers.cookie || ''
+ const m = cookie.match(/(?:^|;\s*)va_token=([^;]+)/)
+ return m ? m[1] : null
+ }
+ function setTokenCookie(res, token) {
+ res.setHeader('Set-Cookie', `va_token=${token}; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=${7 * 24 * 3600}`)
+ }
+ function clearTokenCookie(res) {
+ res.setHeader('Set-Cookie', 'va_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0')
+ }
+
+ // Seed admin if no users exist
+ if (getUsers().length === 0) {
+ const tempPassword = randomBytes(8).toString('hex')
+ saveUsers([{
+ id: randomBytes(8).toString('hex'),
+ login: 'admin',
+ passwordHash: hashPassword(tempPassword),
+ name: 'Администратор',
+ role: 'admin',
+ projects: ['*'],
+ }])
+ console.log(`\n[VA] Создан admin. Временный пароль: ${tempPassword}\n`)
+ }
+
+ // Brute-force protection: 5 attempts per IP per 15 min
+ const loginAttempts = new Map()
+ function checkBruteForce(ip) {
+ const now = Date.now()
+ const entry = loginAttempts.get(ip) || { count: 0, resetAt: now + 15 * 60 * 1000 }
+ if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 15 * 60 * 1000 }
+ if (entry.count >= 5) return false
+ entry.count++
+ loginAttempts.set(ip, entry)
+ return true
+ }
+ function clearBruteForce(ip) { loginAttempts.delete(ip) }
+ setInterval(() => {
+ const now = Date.now()
+ for (const [ip, entry] of loginAttempts) if (now > entry.resetAt) loginAttempts.delete(ip)
+ }, 10 * 60 * 1000)
+
+ // Auth endpoints (before auth middleware)
+ server.middlewares.use(async (req, res, next) => {
+ // Security headers on all responses
+ res.setHeader('X-Content-Type-Options', 'nosniff')
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN')
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
+
+ if (req.url === '/api/auth/login' && req.method === 'POST') {
+ const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'
+ if (!checkBruteForce(ip)) return send(429, { error: 'too_many_attempts' })
+ const body = await readBody(req)
+ const { login, password } = body
+ if (!login || !password) return send(400, { error: 'missing_credentials' })
+ const users = getUsers()
+ const user = users.find(u => u.login === login)
+ if (!user || !verifyPassword(password, user.passwordHash)) return send(401, { error: 'invalid_credentials' })
+ clearBruteForce(ip)
+ const token = createSession(user.id)
+ setTokenCookie(res, token)
+ return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects } })
+ }
+ if (req.url === '/api/auth/logout' && req.method === 'POST') {
+ const token = getTokenFromReq(req)
+ if (token) { sessionsStore.delete(token); saveSessions(sessionsStore) }
+ clearTokenCookie(res)
+ return send(200, { ok: true })
+ }
+ if (req.url === '/api/auth/me' && req.method === 'GET') {
+ const token = getTokenFromReq(req)
+ const session = token ? getSession(token) : null
+ if (!session) return send(401, { error: 'not_authenticated' })
+ const user = getUsers().find(u => u.id === session.userId)
+ if (!user) return send(401, { error: 'user_not_found' })
+ return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects, theme: user.theme || 'light', activePage: user.activePage || null, previewZoom: user.previewZoom || null } })
+ }
+
+ if (req.url === '/api/auth/preferences' && req.method === 'PUT') {
+ const token = getTokenFromReq(req)
+ const session = token ? getSession(token) : null
+ if (!session) return send(401, { error: 'not_authenticated' })
+ const body = await readBody(req)
+ const users = getUsers()
+ const idx = users.findIndex(u => u.id === session.userId)
+ if (idx === -1) return send(404, { error: 'user_not_found' })
+ if (body.theme === 'dark' || body.theme === 'light') users[idx].theme = body.theme
+ if (body.activePage) users[idx].activePage = body.activePage
+ if (typeof body.previewZoom === 'number') users[idx].previewZoom = body.previewZoom
+ saveUsers(users)
+ return send(200, { ok: true })
+ }
+
+ return next()
+
+ function send(status, payload) {
+ res.statusCode = status
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(payload))
+ }
+ })
+
+ // CSRF protection: state-changing requests must come from same host
+ function checkCsrf(req) {
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return true
+ const origin = req.headers['origin']
+ const referer = req.headers['referer']
+ const host = req.headers['host']
+ if (!host) return true
+ const check = origin || referer
+ if (!check) return true // same-origin requests from browser forms don't always send origin
+ try { return new URL(check).host === host } catch { return false }
+ }
+
+ // Auth middleware — protect all /api/ routes (except auth endpoints)
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url?.startsWith('/api/') || req.url.startsWith('/api/auth/')) return next()
+ if (!checkCsrf(req)) { res.statusCode = 403; res.end(JSON.stringify({ error: 'csrf_mismatch' })); return }
+ const token = getTokenFromReq(req)
+ const session = token ? getSession(token) : null
+ if (!session) {
+ res.statusCode = 401
+ res.setHeader('Content-Type', 'application/json')
+ return res.end(JSON.stringify({ error: 'not_authenticated' }))
+ }
+ const user = getUsers().find(u => u.id === session.userId)
+ if (!user) {
+ res.statusCode = 401
+ res.setHeader('Content-Type', 'application/json')
+ return res.end(JSON.stringify({ error: 'user_not_found' }))
+ }
+ req.user = user
+ return next()
+ })
+
+ // Admin endpoints — user management
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url?.startsWith('/api/admin/')) return next()
+ if (req.user?.role !== 'admin') {
+ res.statusCode = 403
+ res.setHeader('Content-Type', 'application/json')
+ return res.end(JSON.stringify({ error: 'forbidden' }))
+ }
+ const send = (status, payload) => {
+ res.statusCode = status
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(payload))
+ }
+ if (req.url === '/api/admin/users' && req.method === 'GET') {
+ const users = getUsers().map(u => ({ id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects }))
+ return send(200, { users })
+ }
+ if (req.url === '/api/admin/users' && req.method === 'POST') {
+ const body = await readBody(req)
+ const { login, password, name, role, projects } = body
+ if (!login || !password) return send(400, { error: 'missing_fields' })
+ if (password.length < 8) return send(400, { error: 'password_too_short' })
+ const users = getUsers()
+ if (users.some(u => u.login === login)) return send(409, { error: 'login_exists' })
+ const newUser = {
+ id: randomBytes(8).toString('hex'),
+ login,
+ passwordHash: hashPassword(password),
+ name: name || login,
+ role: role || 'user',
+ projects: projects || [],
+ }
+ users.push(newUser)
+ saveUsers(users)
+ return send(200, { user: { id: newUser.id, login: newUser.login, name: newUser.name, role: newUser.role, projects: newUser.projects } })
+ }
+ const userMatch = req.url.match(/^\/api\/admin\/users\/([^/]+)$/)
+ if (userMatch) {
+ const userId = decodeURIComponent(userMatch[1])
+ const users = getUsers()
+ const idx = users.findIndex(u => u.id === userId)
+ if (idx === -1) return send(404, { error: 'user_not_found' })
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ if (body.name !== undefined) users[idx].name = body.name
+ if (body.role !== undefined) users[idx].role = body.role
+ if (body.projects !== undefined) users[idx].projects = body.projects
+ if (body.password) users[idx].passwordHash = hashPassword(body.password)
+ if (body.login && body.login !== users[idx].login) {
+ if (users.some((u, i) => i !== idx && u.login === body.login)) return send(409, { error: 'login_exists' })
+ users[idx].login = body.login
+ }
+ saveUsers(users)
+ const u = users[idx]
+ return send(200, { user: { id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects } })
+ }
+ if (req.method === 'DELETE') {
+ if (users[idx].id === req.user.id) return send(400, { error: 'cannot_delete_self' })
+ users.splice(idx, 1)
+ saveUsers(users)
+ return send(200, { ok: true })
+ }
+ }
+ return send(404, { error: 'not_found' })
+ })
+
+ server.middlewares.use(async (req, res, next) => {
+ // Serve uploaded images at /uploads/...
+ if (req.url?.startsWith('/uploads/')) {
+ const filePath = path.resolve(uploadsDir, decodeURIComponent(req.url.replace('/uploads/', '')))
+ if (!filePath.startsWith(uploadsDir)) { res.statusCode = 403; return res.end('Forbidden') }
+ if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') }
+ const ext = path.extname(filePath).toLowerCase()
+ const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }
+ res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
+ return fs.createReadStream(filePath).pipe(res)
+ }
+ return next()
+ })
+
+ // (Google OAuth callback removed — Yonote uses API token)
+
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url.startsWith('/api/')) return next()
+ ensureDir(dataDir)
+
+ const send = (status, payload) => {
+ res.statusCode = status
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(payload))
+ }
+
+ try { return await _handleApi(req, res, next, send) } catch (e) {
+ if (e instanceof BodyError) return send(e.status, { error: e.message })
+ console.error('[VA API]', e)
+ return send(500, { error: 'internal_error' })
+ }
+ })
+
+ async function _handleApi(req, res, next, send) {
+
+ const userCanAccessProject = () => {
+ return !!req.user
+ }
+
+ // Single-project: always return vipavenue
+ if (req.method === 'GET' && req.url === '/api/projects') {
+ return send(200, { projects: [PROJECT_NAME] })
+ }
+
+ if (req.method === 'GET' && req.url.startsWith('/api/parts-files')) {
+ const slug = PROJECT_NAME
+ const partsDir = path.resolve(emailGenRoot, 'emails', slug, 'parts')
+ if (!fs.existsSync(partsDir)) return send(200, { files: [] })
+ const files = []
+ const walk = (dir, base) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const rel = base ? `${base}/${entry.name}` : entry.name
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), rel)
+ else if (entry.name.endsWith('.pug')) files.push(`./parts/${rel.replace(/\.pug$/, '')}`)
+ }
+ }
+ walk(partsDir, '')
+ return send(200, { files })
+ }
+
+ // Read a parts pug file
+ if (req.method === 'GET' && req.url.startsWith('/api/parts-file-read')) {
+ const u = new URL(req.url, 'http://localhost')
+ const relPath = u.searchParams.get('path') // e.g. "./parts/header/header-woman"
+ if (!relPath) return send(400, { error: 'no path' })
+ const partsDir = path.resolve(emailGenRoot, 'emails', PROJECT_NAME, 'parts')
+ const normalized = relPath.replace(/^\.\/parts\//, '').replace(/\.pug$/, '')
+ const filePath = path.resolve(partsDir, normalized + '.pug')
+ if (!filePath.startsWith(partsDir)) return send(403, { error: 'forbidden' })
+ if (!fs.existsSync(filePath)) return send(404, { error: 'not found' })
+ return send(200, { content: fs.readFileSync(filePath, 'utf8') })
+ }
+
+ // Write a parts pug file
+ if (req.method === 'POST' && req.url === '/api/parts-file-write') {
+ if (!userCanAccessProject()) return send(403, { error: 'forbidden' })
+ const body = await readBody(req)
+ const { path: relPath, content } = body
+ if (!relPath || typeof content !== 'string') return send(400, { error: 'bad request' })
+ const partsDir = path.resolve(emailGenRoot, 'emails', PROJECT_NAME, 'parts')
+ const normalized = relPath.replace(/^\.\/parts\//, '').replace(/\.pug$/, '')
+ const filePath = path.resolve(partsDir, normalized + '.pug')
+ if (!filePath.startsWith(partsDir)) return send(403, { error: 'forbidden' })
+ fs.writeFileSync(filePath, content, 'utf8')
+ return send(200, { ok: true })
+ }
+
+ // Project access gate
+ const projectAccessMatch = req.url.match(/^\/api\/project\/([^/]+)/)
+ if (projectAccessMatch) {
+ if (!userCanAccessProject()) return send(403, { error: 'no_project_access' })
+ }
+
+ // Helper: per-user letters directory
+ const getUserLettersDir = (projectDir) => {
+ const uid = req.user?.id || '_default'
+ const d = path.resolve(projectDir, 'letters', uid)
+ ensureDir(d)
+ return d
+ }
+ const getUserLettersFile = (projectDir) => {
+ const uid = req.user?.id || '_default'
+ const d = path.resolve(projectDir, 'letters', uid)
+ ensureDir(d)
+ return path.resolve(d, '_index.json')
+ }
+
+ const lettersMatch = req.url.match(/^\/api\/project\/([^/]+)\/letters$/)
+ if (lettersMatch) {
+ const name = decodeURIComponent(lettersMatch[1])
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ ensureDir(dir)
+ const lettersFile = getUserLettersFile(dir)
+ if (req.method === 'GET') {
+ const letters = readJson(lettersFile, { list: [], currentId: '' })
+ return send(200, letters)
+ }
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ const next = {
+ list: Array.isArray(body.list) ? body.list : [],
+ currentId: typeof body.currentId === 'string' ? body.currentId : '',
+ }
+ writeJson(lettersFile, next)
+ return send(200, { ok: true })
+ }
+ }
+
+ const historyMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter\/([^/]+)\/history$/)
+ if (historyMatch) {
+ const name = decodeURIComponent(historyMatch[1])
+ const id = sanitizeFileId(historyMatch[2])
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ const lettersDir = getUserLettersDir(dir)
+ const histFile = path.resolve(lettersDir, `${id}.history.json`)
+ if (req.method === 'GET') {
+ return send(200, { history: readJson(histFile, []) })
+ }
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ const snapshot = body?.snapshot
+ if (!snapshot) return send(400, { error: 'missing_snapshot' })
+ const history = readJson(histFile, [])
+ history.unshift(snapshot)
+ if (history.length > 20) history.splice(20)
+ writeJson(histFile, history)
+ return send(200, { ok: true })
+ }
+ }
+
+ const letterMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter(?:\/([^/]+))?$/)
+ if (letterMatch) {
+ const name = decodeURIComponent(letterMatch[1])
+ const id = letterMatch[2] ? sanitizeFileId(letterMatch[2]) : ''
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ const lettersDir = getUserLettersDir(dir)
+ if (req.method === 'GET' && id) {
+ const file = path.resolve(lettersDir, `${id}.json`)
+ const letter = readJson(file, null)
+ return send(200, { letter })
+ }
+ if (req.method === 'DELETE' && id) {
+ const file = path.resolve(lettersDir, `${id}.json`)
+ if (fs.existsSync(file)) fs.unlinkSync(file)
+ return send(200, { ok: true })
+ }
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ const letterId = sanitizeFileId(body.id)
+ if (!letterId) return send(400, { error: 'missing_id' })
+ const file = path.resolve(lettersDir, `${letterId}.json`)
+ writeJson(file, body || {})
+ return send(200, { ok: true })
+ }
+ }
+
+ const notesMatch = req.url.match(/^\/api\/project\/([^/]+)\/notes$/)
+ if (notesMatch) {
+ const name = decodeURIComponent(notesMatch[1])
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ ensureDir(dir)
+ const notesFile = path.resolve(dir, 'notes.json')
+ if (req.method === 'GET') {
+ const notes = readJson(notesFile, { list: [], currentId: '' })
+ return send(200, notes)
+ }
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ const next = {
+ list: Array.isArray(body.list) ? body.list : [],
+ currentId: typeof body.currentId === 'string' ? body.currentId : '',
+ }
+ writeJson(notesFile, next)
+ return send(200, { ok: true })
+ }
+ }
+
+ const noteMatch = req.url.match(/^\/api\/project\/([^/]+)\/note(?:\/([^/]+))?$/)
+ if (noteMatch) {
+ const name = decodeURIComponent(noteMatch[1])
+ const id = noteMatch[2] ? sanitizeFileId(noteMatch[2]) : ''
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ const notesDir = path.resolve(dir, 'notes')
+ ensureDir(notesDir)
+ if (req.method === 'GET' && id) {
+ const file = path.resolve(notesDir, `${id}.json`)
+ const note = readJson(file, null)
+ return send(200, { note })
+ }
+ if (req.method === 'DELETE' && id) {
+ const file = path.resolve(notesDir, `${id}.json`)
+ if (fs.existsSync(file)) fs.unlinkSync(file)
+ return send(200, { ok: true })
+ }
+ if (req.method === 'PUT') {
+ const body = await readBody(req)
+ const noteId = sanitizeFileId(body.id)
+ if (!noteId) return send(400, { error: 'missing_id' })
+ const file = path.resolve(notesDir, `${noteId}.json`)
+ writeJson(file, body || {})
+ return send(200, { ok: true })
+ }
+ }
+
+ const renderMatch = req.url.match(/^\/api\/project\/([^/]+)\/render-email$/)
+ if (renderMatch && req.method === 'POST') {
+ if (activeRenders >= MAX_CONCURRENT_RENDERS) return send(429, { error: 'Слишком много параллельных рендеров, подождите' })
+ activeRenders++
+ try {
+ const projectName = decodeURIComponent(renderMatch[1])
+ const body = await readBody(req)
+ const slug = sanitizeProjectSlug(body.projectSlug)
+ const pug = String(body.pug || '').replace(/([#!])\{/g, '$1\\{') // escape Pug interpolation to prevent code injection
+ const preheader = String(body.preheader || '').replace(/[\r\n`\\]/g, '').replace(/"/g, '\\"')
+ const gender = String(body.gender || 'female')
+ if (!slug) return send(400, { error: 'missing_project_slug', details: 'Укажи папку проекта в email-gen' })
+ if (!pug.trim()) return send(400, { error: 'missing_pug', details: 'PUG пустой, нечего генерировать' })
+
+ // Start feed loading in parallel with render (await before Mindbox processing)
+ const projDir = getProjectDir(projectName)
+ const projSettings = projDir ? readJson(path.resolve(projDir, 'settings.json'), {}) : {}
+ const genderPaths = projSettings.genderPaths || {}
+ const feedUrl = projSettings.feedUrl || ''
+ const feedPromise = feedUrl ? getFeedProducts(feedUrl).catch(() => null) : null
+
+ // Check render cache by Pug hash
+ const hash = pugHash(slug, pug, gender, genderPaths)
+ let rawHtml = renderCache.get(hash)
+
+ if (!rawHtml) {
+ if (emailGenApiUrl) {
+ try {
+ const forward = await fetch(`${emailGenApiUrl.replace(/\/$/, '')}/render`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ projectSlug: slug, pug, preheader, gender, genderPaths }),
+ signal: AbortSignal.timeout(45000),
+ })
+ const payload = await forward.json().catch(() => ({}))
+ if (!forward.ok) {
+ return send(forward.status, {
+ error: payload.error || 'render_failed',
+ details: payload.details || 'Не удалось получить ответ от email-gen api',
+ })
+ }
+ rawHtml = payload.html || ''
+ } catch (error) {
+ return send(500, {
+ error: 'email_gen_api_unreachable',
+ details: error?.message || 'email-gen api недоступен',
+ })
+ }
+ } else {
+ // Local render fallback
+ if (!fs.existsSync(emailGenRoot)) return send(500, { error: 'email_gen_not_found', details: 'Папка email-gen не найдена рядом с проектом' })
+
+ const emailProjectDir = path.resolve(emailGenRoot, 'emails', slug)
+ if (!emailProjectDir.startsWith(path.resolve(emailGenRoot, 'emails'))) {
+ return send(400, { error: 'invalid_project_slug', details: 'Некорректное имя проекта email-gen' })
+ }
+ if (!fs.existsSync(emailProjectDir)) {
+ return send(404, { error: 'email_project_not_found', details: `Проект "${slug}" не найден в email-gen/emails` })
+ }
+
+ const lettersDir = path.resolve(emailProjectDir, 'letters')
+ ensureDir(lettersDir)
+ fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8')
+
+ // Rewrite html.pug to point content to let.pug and inject preheader
+ const htmlPugPath = path.resolve(emailProjectDir, 'html.pug')
+ const projSettings = readJson(path.resolve(dataDir, PROJECT_NAME, 'settings.json'), {})
+ const genderPaths = projSettings.genderPaths || {}
+ function sanitizePartPath(p, fallback) {
+ if (!p || typeof p !== 'string') return fallback
+ // Разрешены только пути вида ./parts/... без ..
+ const clean = p.replace(/\\/g, '/').replace(/\/+/g, '/')
+ if (/\.\./.test(clean) || /[\0\r\n]/.test(clean)) return fallback
+ if (!clean.startsWith('./parts/') && !clean.startsWith('parts/')) return fallback
+ return clean
+ }
+ const headerPath = gender === 'male'
+ ? sanitizePartPath(genderPaths.headerMale, './parts/header/header-man')
+ : sanitizePartPath(genderPaths.headerFemale, './parts/header/header-woman')
+ const footerPath = gender === 'male'
+ ? sanitizePartPath(genderPaths.footerMale, './parts/footer/footer-man')
+ : sanitizePartPath(genderPaths.footerFemale, './parts/footer/footer-woman')
+ const htmlPugContent = [
+ 'extends layout/layout.pug',
+ '',
+ 'block header',
+ ` include ${headerPath}`,
+ ...(preheader
+ ? ['block preheader', ` +preheader("${preheader}")`]
+ : ['block preheader', ' +preheader("")']),
+ 'block content',
+ ' include ./letters/let.pug',
+ 'block footer',
+ ` include ${footerPath}`,
+ '',
+ ].join('\n')
+ fs.writeFileSync(htmlPugPath, htmlPugContent, 'utf-8')
+
+ const renderScript = `
+const path = require('path');
+const fs = require('fs');
+const Email = require('email-templates');
+async function run() {
+ const project = process.argv[1];
+ const root = process.argv[2];
+ const email = new Email();
+ const html = await email.render({
+ path: project + '/html',
+ juiceResources: {
+ preserveImportant: true,
+ applyStyleTags: true,
+ removeStyleTags: true,
+ preserveMediaQueries: true,
+ webResources: {
+ relativeTo: path.resolve(root, 'emails', project)
+ }
+ },
+ }, { pretty: true });
+ const outDir = path.resolve(root, 'public');
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
+ const outPath = path.resolve(outDir, 'index.html');
+ fs.writeFileSync(outPath, String(html).replace('#MAILRU_PREHEADER_TAG#', ' '), 'utf-8');
+}
+run().catch((error) => {
+ console.error(error && error.stack ? error.stack : String(error));
+ process.exit(1);
+});
+`
+
+ const run = spawnSync(process.execPath, ['-e', renderScript, slug, emailGenRoot], {
+ cwd: emailGenRoot,
+ encoding: 'utf-8',
+ shell: false,
+ timeout: 120000,
+ })
+ if (run.error || run.status !== 0) {
+ return send(500, {
+ error: 'render_failed',
+ details: 'Ошибка генерации. Проверьте PUG-шаблон.',
+ })
+ }
+
+ const previewFile = path.resolve(emailGenRoot, 'public', 'index.html')
+ if (!fs.existsSync(previewFile)) {
+ return send(500, { error: 'preview_not_found', details: 'email-gen не создал public/index.html' })
+ }
+
+ rawHtml = fs.readFileSync(previewFile, 'utf-8')
+ }
+ // Cache the rendered HTML
+ setRenderCache(hash, rawHtml)
+ } // end of if (!rawHtml) — cache miss
+
+ // Ensure feed is loaded before Mindbox processing
+ if (feedPromise) await feedPromise
+
+ // Process Mindbox tags server-side
+ const mindbox = await processMindboxTags(rawHtml, feedUrl)
+
+ // Wrap prepositions/conjunctions + next word in nowrap spans
+ const nowrapHtml = applyNowrap(rawHtml)
+ const nowrapPreview = applyNowrap(mindbox.html)
+
+ const feedCacheEntry = feedUrl ? feedCache.get(feedUrl) : null
+ return send(200, {
+ html: nowrapHtml,
+ previewHtml: nowrapPreview,
+ unavailableProducts: mindbox.unavailable,
+ feedSyncedAt: feedCacheEntry ? feedCacheEntry.ts : null,
+ generatedAt: new Date().toISOString(),
+ })
+ } finally { activeRenders-- }
+ }
+
+ if (req.url === '/api/config') {
+ if (req.method === 'GET') {
+ const c = readJson(configFile, {})
+ return send(200, {
+ yonote_token: c.yonote_token ? '***' : '',
+ yonote_base_url: c.yonote_base_url || '',
+ hasYonoteToken: Boolean(c.yonote_token),
+ upload_base_url: c.upload_base_url || '',
+ })
+ }
+ if (req.method === 'PUT') {
+ if (req.user?.role !== 'admin') return send(403, { error: 'Только admin может менять конфигурацию' })
+ const body = await readBody(req)
+ const existing = readJson(configFile, {})
+ const updated = { ...existing }
+ if (body.yonote_token !== undefined) updated.yonote_token = String(body.yonote_token).trim()
+ if (body.yonote_base_url !== undefined) updated.yonote_base_url = String(body.yonote_base_url).trim()
+ if (body.upload_base_url !== undefined) updated.upload_base_url = String(body.upload_base_url).trim()
+ writeJson(configFile, updated)
+ return send(200, { ok: true })
+ }
+ }
+
+ if (req.method === 'POST' && req.url === '/api/upload-image') {
+ const body = await readBody(req)
+ if (!body.imageData) return send(400, { error: 'Нет данных изображения' })
+ const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/)
+ if (!dataMatch) return send(400, { error: 'Неверный формат изображения' })
+ const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
+ const ext = TYPES[dataMatch[1]]
+ if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` })
+ const buffer = Buffer.from(dataMatch[2], 'base64')
+ if (buffer.length > 20 * 1024 * 1024) return send(400, { error: 'Файл слишком большой (макс. 20 МБ)' })
+ const slug = (body.projectName || 'default').replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_')
+ const key = `${slug}/${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`
+ const filePath = path.resolve(uploadsDir, key)
+ if (!filePath.startsWith(uploadsDir + path.sep)) return send(400, { error: 'Недопустимый путь' })
+ try {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
+ fs.writeFileSync(filePath, buffer)
+ const config = readJson(configFile, {})
+ const baseUrl = config.upload_base_url ? config.upload_base_url.replace(/\/$/, '') : ''
+ const url = baseUrl ? `${baseUrl}/uploads/${key}` : `/uploads/${key}`
+ return send(200, { url })
+ } catch (e) { return send(500, { error: e?.message || 'Ошибка сохранения файла' }) }
+ }
+
+ // --- Link checking endpoint ---
+ if (req.method === 'POST' && req.url === '/api/check-links') {
+ const body = await readBody(req)
+ const urls = Array.isArray(body.urls) ? body.urls.filter(u => /^https?:\/\//i.test(u) && !/^https?:\/\/(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i.test(u)).slice(0, 50) : []
+ if (!urls.length) return send(400, { error: 'no_urls' })
+ const results = await Promise.allSettled(
+ urls.map(async (url) => {
+ try {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 10000)
+ const res = await fetch(url, {
+ method: 'HEAD', redirect: 'follow', signal: controller.signal,
+ headers: { 'User-Agent': 'AspekterVA-LinkChecker/1.0' },
+ })
+ clearTimeout(timeout)
+ return { url, status: res.status, ok: res.ok, redirected: res.redirected, finalUrl: res.url }
+ } catch (e) {
+ return { url, status: 0, ok: false, error: e?.message || 'Ошибка соединения' }
+ }
+ })
+ )
+ return send(200, { results: results.map(r => r.status === 'fulfilled' ? r.value : { url: '', status: 0, ok: false, error: 'error' }) })
+ }
+
+ // --- Product feed endpoint (cached, lookup by IDs) ---
+ function getFeedProducts(feedUrl) {
+ const cached = feedCache.get(feedUrl)
+ if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return Promise.resolve(cached.products)
+ // Deduplicate in-flight requests, but expire stale pending after 2 min
+ if (feedPending.has(feedUrl)) {
+ const p = feedPending.get(feedUrl)
+ if (Date.now() - p._startedAt < 120000) return p
+ feedPending.delete(feedUrl)
+ }
+ const promise = _fetchFeedProducts(feedUrl)
+ .finally(() => feedPending.delete(feedUrl))
+ promise._startedAt = Date.now()
+ feedPending.set(feedUrl, promise)
+ return promise
+ }
+ function isPublicUrl(url) {
+ if (!/^https?:\/\//i.test(url)) return false
+ if (/^https?:\/\/(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|0x|\[|::)/i.test(url)) return false
+ try { const u = new URL(url); if (u.hostname === '0.0.0.0' || u.hostname.includes(':') || u.hostname.includes('[')) return false } catch { return false }
+ return true
+ }
+ async function _fetchFeedProducts(feedUrl) {
+ if (!isPublicUrl(feedUrl)) throw new Error('Feed URL must be a public HTTP(S) address')
+ const resp = await fetch(feedUrl, {
+ signal: AbortSignal.timeout(90000),
+ headers: { 'User-Agent': 'AspekterVA-FeedReader/1.0' },
+ })
+ if (!resp.ok) throw new Error(`Фид вернул ${resp.status}`)
+ // Handle windows-1251 encoding
+ const buf = await resp.arrayBuffer()
+ const contentType = resp.headers.get('content-type') || ''
+ let text
+ if (contentType.includes('1251') || contentType.includes('windows')) {
+ const decoder = new TextDecoder('windows-1251')
+ text = decoder.decode(buf)
+ } else {
+ // Try to detect from XML declaration
+ const preview = new TextDecoder('ascii').decode(buf.slice(0, 200))
+ const encMatch = preview.match(/encoding=["']([^"']+)["']/i)
+ const enc = encMatch ? encMatch[1] : 'utf-8'
+ try { text = new TextDecoder(enc).decode(buf) } catch { text = new TextDecoder('utf-8').decode(buf) }
+ }
+ const products = new Map()
+ const offerRegex = /]*)>([\s\S]*?)<\/offer>/gi
+ let m
+ while ((m = offerRegex.exec(text)) !== null) {
+ const id = m[1], attrs = m[2], block = m[3]
+ const availAttr = attrs.match(/available="([^"]*)"/i)
+ const available = availAttr ? availAttr[1].toLowerCase() === 'true' : true
+ const tag = (t) => { const x = block.match(new RegExp(`<${t}[^>]*>([\\s\\S]*?)<\\/${t}>`,'i')); return x ? x[1].trim() : '' }
+ const param = (n) => { const x = block.match(new RegExp(` ]*>([\\s\\S]*?)<\\/param>`,'i')); return x ? x[1].trim() : '' }
+ const vendor = tag('vendor')
+ const typePrefix = tag('typePrefix')
+ const model = tag('model')
+ const rawName = tag('name') || tag('title')
+ // For feeds without , compose from typePrefix + vendor (без артикула model)
+ const name = rawName || [typePrefix, vendor].filter(Boolean).join(' ')
+ const seriesMatch = name.match(/[«""]([^»""]+)[»""]/)
+ const gender = param('Gender')
+ const color = param('Цвет')
+ products.set(id, {
+ id, available, name, price: tag('price'), oldPrice: tag('oldprice'),
+ image: tag('picture'), url: tag('url'), description: tag('description'),
+ categoryId: tag('categoryId'),
+ vendor, typePrefix, model, gender, color,
+ discountPercent: param('DiscountPercent'),
+ series: seriesMatch ? seriesMatch[1] : '',
+ denomination: tag('denomination'), year: tag('year'), dia: tag('dia'),
+ material: tag('material'), country: tag('country'), condition: tag('condition'),
+ weight: tag('Weight') || tag('weight'), assay: tag('assay'),
+ vendorCode: tag('vendorCode'), reverseImage: tag('reversePictureUrl'),
+ salePercent: tag('SalePercent') || tag('salepercent'),
+ })
+ }
+ const now = Date.now()
+ feedCache.set(feedUrl, { ts: now, products })
+ saveFeedCacheToDisk(feedUrl, now, products)
+ return products
+ }
+
+ // --- Server-side Mindbox tag processing ---
+ function resolveProductProp(product, propPath) {
+ if (!product) return ''
+ const prop = propPath.toLowerCase()
+ if (prop === 'name') return product.name || ''
+ if (prop === 'vendorname' || prop === 'vendor') return product.vendor || ''
+ if (prop === 'url') return product.url || ''
+ if (prop === 'pictureurl' || prop === 'picture' || prop === 'imageurl') return product.image || ''
+ if (prop === 'price') return product.price || ''
+ if (prop === 'oldprice') return product.oldPrice || ''
+ if (prop === 'description') return product.description || ''
+ const cfMatch = propPath.match(/^(?:customfield|additionaldata)\.(\w+)$/i)
+ if (cfMatch) {
+ const cf = cfMatch[1].toLowerCase()
+ if (cf === 'denomination' || cf === 'nominal') return product.denomination || ''
+ if (cf === 'year' || cf === 'god') return product.year || ''
+ if (cf === 'dia' || cf === 'diameter' || cf === 'diametr') return product.dia || ''
+ if (cf === 'material') return product.material || ''
+ if (cf === 'country' || cf === 'strana') return product.country || ''
+ if (cf === 'condition' || cf === 'sohrannost' || cf === 'soxrannost') return product.condition || ''
+ if (cf === 'weight' || cf === 'ves') return product.weight || ''
+ if (cf === 'assay' || cf === 'proba') return product.assay || ''
+ if (cf === 'vendorcode' || cf === 'artikul') return product.vendorCode || ''
+ if (cf === 'reversepictureurl') return product.reverseImage || ''
+ if (cf === 'salepercent' || cf === 'sale') return product.salePercent || ''
+ if (cf === 'discountpercent' || cf === 'discount') return product.discountPercent || ''
+ return product[cfMatch[1]] || product[cf] || ''
+ }
+ return ''
+ }
+
+ function applyNowrap(html) {
+ function wrapShort(text) {
+ return text.replace(/(?
+ const result = html.replace(/(]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi, (_match, open, content, close) => {
+ const processed = content.replace(/>([^<]+) '>' + wrapShort(t) + '<')
+ const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m))
+ return open + firstText + close
+ })
+ // Replace placeholders with actual span tags (prevents double-processing)
+ return result.replace(/\u200Bspan\u200Bnwr\u200B/g, '').replace(/\u200B\/span\u200Bnwr\u200B/g, ' ')
+ }
+
+ async function processMindboxTags(html, feedUrl) {
+ const noResult = { html, unavailable: [] }
+ if (!feedUrl) return noResult
+ let work = html.replace(/'/g, "'").replace(/'/g, "'")
+ // Remove @{for...}@{end for} blocks FIRST so recommendation IDs don't leak
+ work = work.replace(/@\{\s*for\s[^}]*\}[\s\S]*?@\{\s*end\s+for\s*\}/gi, '')
+ const idRegex = /GetByValue\(["'](\d+)["']\)/g
+ const ids = new Set()
+ let m
+ while ((m = idRegex.exec(work)) !== null) ids.add(m[1])
+ if (!ids.size) return noResult
+ let allProducts
+ try { allProducts = await getFeedProducts(feedUrl) } catch (e) { return noResult }
+ const products = {}
+ const unavailable = []
+ for (const id of ids) {
+ const p = allProducts.get(String(id))
+ if (p) {
+ products[id] = p
+ if (!p.available) unavailable.push({ id, name: p.name, price: p.price })
+ }
+ }
+ if (!Object.keys(products).length) return noResult
+ let result = work
+ // Collect @{ set var = value } Mindbox variables first
+ const mbVars = {}
+ result = result.replace(/@\{\s*set\s+(\w+)\s*=\s*([^}]*)\}/gi, (_, name, val) => {
+ mbVars[name.toLowerCase()] = val.trim()
+ return ''
+ })
+ // Check if compound condition's simple var prerequisites are met
+ function compoundVarsOk(condStr) {
+ // Extract "varName > 0" parts that are NOT inside GetByValue (simple Mindbox vars)
+ const parts = condStr.split(/\bAND\b/gi)
+ for (const part of parts) {
+ if (/GetByValue|Products\.|SearchInIdentity/i.test(part)) continue
+ const m = part.match(/(\w+)\s*>\s*0/)
+ if (m) {
+ const v = mbVars[m[1].toLowerCase()]
+ if (!v || Number(v) <= 0) return false
+ }
+ }
+ return true
+ }
+ // Handle @{ if ...DiscountPercent > 0 }...@{ end if } — hide block when discount is 0
+ result = result.replace(/@\{\s*if\s([^}]*?GetByValue\(["'](\d+)["']\)[^}]*?DiscountPercent\s*>\s*0[^}]*)\}([\s\S]*?)@\{\s*end\s+if\s*\}/gi, (_, cond, id, content) => {
+ if (!compoundVarsOk(cond)) return ''
+ const p = products[id]
+ const discount = p ? Number(p.discountPercent || 0) : 0
+ return discount > 0 ? content : ''
+ })
+ // Handle @{ if ...OldPrice > ...Price }...@{ end if } — hide when no real discount
+ result = result.replace(/@\{\s*if\s([^}]*?GetByValue\(["'](\d+)["']\)[^}]*?OldPrice\s*>\s*[^}]*?Price[^}]*)\}([\s\S]*?)@\{\s*end\s+if\s*\}/gi, (_, cond, id, content) => {
+ if (!compoundVarsOk(cond)) return ''
+ const p = products[id]
+ if (!p) return ''
+ return Number(p.oldPrice || 0) > Number(p.price || 0) ? content : ''
+ })
+ // Handle @{ if simpleVar > 0 }...@{ end if } with proper nesting
+ const ifOpenRe = /@\{\s*if\s/gi
+ const endIfRe = /@\{\s*end\s+if\s*\}/gi
+ function resolveSimpleVarBlocks(text) {
+ // Find @{ if varName > 0 } (simple, no AND/OR/GetByValue)
+ const simpleIfRe = /@\{\s*if\s+(\w+)\s*>\s*0\s*\}/gi
+ let match
+ while ((match = simpleIfRe.exec(text)) !== null) {
+ const varName = match[1]
+ const blockStart = match.index
+ const contentStart = blockStart + match[0].length
+ // Find matching @{ end if } counting nesting depth
+ let depth = 1, pos = contentStart
+ let innerIf, innerEnd
+ while (depth > 0) {
+ ifOpenRe.lastIndex = pos
+ endIfRe.lastIndex = pos
+ innerIf = ifOpenRe.exec(text)
+ innerEnd = endIfRe.exec(text)
+ if (!innerEnd) break // malformed, bail
+ if (innerIf && innerIf.index < innerEnd.index) {
+ depth++
+ pos = innerIf.index + innerIf[0].length
+ } else {
+ depth--
+ if (depth === 0) {
+ const content = text.slice(contentStart, innerEnd.index)
+ const blockEnd = innerEnd.index + innerEnd[0].length
+ const v = mbVars[varName.toLowerCase()]
+ const keep = v && Number(v) > 0
+ text = text.slice(0, blockStart) + (keep ? content : '') + text.slice(blockEnd)
+ return resolveSimpleVarBlocks(text) // recurse for remaining
+ }
+ pos = innerEnd.index + innerEnd[0].length
+ }
+ }
+ break // malformed nesting, stop
+ }
+ return text
+ }
+ result = resolveSimpleVarBlocks(result)
+ result = result.replace(/@\{\s*if\s[^}]*\}/gi, '')
+ result = result.replace(/@\{\s*end\s+if\s*\}/gi, '')
+ result = result.replace(/\$\{\s*formatDecimal\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => {
+ const p = products[id]; if (!p) return ''
+ const val = resolveProductProp(p, propPath)
+ return val ? Number(val).toLocaleString('ru-RU') : ''
+ })
+ result = result.replace(/\$\{\s*ResizeImage\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => {
+ return resolveProductProp(products[id], propPath)
+ })
+ result = result.replace(/\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}/gi, (_, id, propPath) => {
+ return resolveProductProp(products[id], propPath)
+ })
+ result = result.replace(/-\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}%/gi, (_, id, propPath) => {
+ const val = resolveProductProp(products[id], propPath)
+ return val && Number(val) > 0 ? `- ${val}%` : ''
+ })
+ result = result.replace(/\$\{\s*category\.\w+\s*\}/gi, '')
+ result = result.replace(/@\{[^}]*\}/g, '')
+ result = result.replace(/\$\{\s*[^}]*(?:Products\.|ResizeImage|formatDecimal)[^}]*\}/g, '')
+ // Add "Нет в наличии" overlay for unavailable products in preview
+ const badge = 'Нет в наличии
'
+ for (const item of unavailable) {
+ const img = products[item.id]?.image
+ if (img) {
+ const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const imgRegex = new RegExp(`( ]*src=["']${imgEsc}["'][^>]*>)`, 'i')
+ result = result.replace(imgRegex, `$1${badge}
`)
+ }
+ }
+ return { html: result, unavailable }
+ }
+
+ // --- Feed refresh (clear cache + re-fetch, with diff) ---
+ const feedRefreshMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-refresh$/)
+ if (feedRefreshMatch && req.method === 'POST') {
+ const projectName = decodeURIComponent(feedRefreshMatch[1])
+ const projDir = getProjectDir(projectName)
+ if (!projDir) return send(400, { error: 'invalid_project_name' })
+ const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
+ const feedUrl = projSettings.feedUrl
+ if (!feedUrl) return send(400, { error: 'Фид не настроен' })
+ // Remember old IDs before refresh
+ const oldCached = feedCache.get(feedUrl)
+ const oldIds = oldCached ? new Set(oldCached.products.keys()) : null
+ feedCache.delete(feedUrl)
+ feedPending.delete(feedUrl)
+ try {
+ const products = await getFeedProducts(feedUrl)
+ const newIds = new Set(products.keys())
+ const diff = { count: products.size }
+ if (oldIds) {
+ const added = [...newIds].filter(id => !oldIds.has(id))
+ const removed = [...oldIds].filter(id => !newIds.has(id))
+ diff.added = added.length
+ diff.removed = removed.length
+ diff.addedProducts = added.slice(0, 10).map(id => {
+ const p = products.get(id)
+ return { id, name: p?.name || '' }
+ })
+ }
+ return send(200, diff)
+ } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
+ }
+
+ const feedLookupMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-lookup$/)
+ if (feedLookupMatch && req.method === 'POST') {
+ const projectName = decodeURIComponent(feedLookupMatch[1])
+ const projDir = getProjectDir(projectName)
+ if (!projDir) return send(400, { error: 'invalid_project_name' })
+ const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
+ const feedUrl = projSettings.feedUrl
+ if (!feedUrl) return send(400, { error: 'Фид не настроен' })
+ const body = await readBody(req)
+ const ids = Array.isArray(body.ids) ? body.ids : []
+ try {
+ const allProducts = await getFeedProducts(feedUrl)
+ const result = {}
+ for (const id of ids) {
+ const p = allProducts.get(String(id))
+ if (p) result[id] = p
+ }
+ return send(200, { products: result })
+ } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
+ }
+
+ // --- Feed suggest replacements ---
+ const feedSuggestMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-suggest$/)
+ if (feedSuggestMatch && req.method === 'POST') {
+ const projectName = decodeURIComponent(feedSuggestMatch[1])
+ const projDir = getProjectDir(projectName)
+ if (!projDir) return send(400, { error: 'invalid_project_name' })
+ const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
+ const feedUrl = projSettings.feedUrl
+ if (!feedUrl) return send(400, { error: 'Фид не настроен' })
+ const body = await readBody(req)
+ const productId = String(body.productId || '')
+ const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String))
+ const search = String(body.search || '').toLowerCase().trim()
+ if (!productId) return send(400, { error: 'missing productId' })
+ try {
+ const allProducts = await getFeedProducts(feedUrl)
+ const source = allProducts.get(productId)
+ if (!source) return send(404, { error: 'Товар не найден в фиде' })
+ const candidates = []
+ for (const [id, p] of allProducts) {
+ if (id === productId || excludeIds.has(id) || !p.available) continue
+ // If search query provided, filter by name/id match
+ if (search && !(p.name || '').toLowerCase().includes(search) && !id.includes(search)) continue
+ let score = 0
+ if (!search) {
+ // 1. Тот же тип товара (typePrefix) — базовое условие (+20)
+ if (source.typePrefix && p.typePrefix && source.typePrefix.toLowerCase() === p.typePrefix.toLowerCase()) score += 20
+ // 2. Та же категория (+15)
+ if (source.categoryId && p.categoryId === source.categoryId) score += 15
+ // 3. Тот же бренд/vendor (+25)
+ if (source.vendor && p.vendor && source.vendor.toLowerCase() === p.vendor.toLowerCase()) score += 25
+ // 4. Тот же гендер (+10)
+ if (source.gender && p.gender && source.gender.toLowerCase() === p.gender.toLowerCase()) score += 10
+ // 5. Цена — жёсткий фильтр >3x, бонус за близость (+0..+10)
+ const srcPrice = Number(source.price) || 0
+ const pPrice = Number(p.price) || 0
+ if (srcPrice > 0 && pPrice > 0) {
+ const ratio = Math.max(pPrice, srcPrice) / Math.min(pPrice, srcPrice)
+ if (ratio > 3) continue // исключаем если цена отличается более чем в 3 раза
+ if (ratio <= 1.1) score += 10
+ else if (ratio <= 1.3) score += 7
+ else if (ratio <= 2.0) score += 3
+ }
+ // 6. Тот же цвет (+5)
+ if (source.color && p.color && source.color.toLowerCase() === p.color.toLowerCase()) score += 5
+ // Минимальный порог — хотя бы тип или категория должны совпасть
+ if (score === 0) continue
+ }
+ candidates.push({ ...p, score })
+ }
+ candidates.sort((a, b) => b.score - a.score || (Number(b.price) || 0) - (Number(a.price) || 0))
+ const suggestions = candidates.slice(0, 20).map(p => ({
+ id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice,
+ image: p.image, url: p.url, series: p.series, categoryId: p.categoryId,
+ vendor: p.vendor, typePrefix: p.typePrefix, color: p.color, gender: p.gender,
+ }))
+ return send(200, { source: { id: source.id, name: source.name }, suggestions })
+ } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
+ }
+
+ // --- FTP/SFTP endpoints ---
+ const ftpMatch = req.url.match(/^\/api\/project\/([^/]+)\/ftp\/(test|upload|list|delete)$/)
+ if (ftpMatch && req.method === 'POST') {
+ const projectName = decodeURIComponent(ftpMatch[1])
+ const ftpAction = ftpMatch[2]
+ const projDir = getProjectDir(projectName)
+ if (!projDir) return send(400, { error: 'invalid_project_name' })
+ const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
+ const fc = projSettings.ftpConfig || {}
+ if (!fc.host) return send(400, { error: 'FTP не настроен для этого проекта' })
+
+ const connectFtp = async () => {
+ const { Client } = await import('basic-ftp')
+ const client = new Client()
+ client.ftp.verbose = false
+ client.ftp.timeout = 15000
+ await client.access({
+ host: fc.host,
+ port: parseInt(fc.port) || 21,
+ user: fc.user,
+ password: fc.password,
+ secure: false,
+ })
+ return client
+ }
+ const connectSftp = async () => {
+ const SftpClient = (await import('ssh2-sftp-client')).default
+ const sftp = new SftpClient()
+ await sftp.connect({
+ host: fc.host,
+ port: parseInt(fc.port) || 22,
+ username: fc.user,
+ password: fc.password,
+ readyTimeout: 15000,
+ })
+ return sftp
+ }
+
+ if (ftpAction === 'test') {
+ try {
+ if (fc.protocol === 'sftp') {
+ const sftp = await connectSftp()
+ const exists = await sftp.exists(fc.remotePath || '/')
+ await sftp.end()
+ return send(200, { ok: true, message: `Подключено. Путь ${fc.remotePath || '/'} ${exists ? 'существует' : 'не найден (будет создан)'}` })
+ } else {
+ const client = await connectFtp()
+ await client.ensureDir(fc.remotePath || '/')
+ client.close()
+ return send(200, { ok: true, message: 'Подключено. Путь доступен.' })
+ }
+ } catch (e) {
+ return send(500, { error: 'connection_failed', details: e?.message || 'Не удалось подключиться' })
+ }
+ }
+
+ if (ftpAction === 'upload') {
+ const body = await readBody(req)
+ if (!body.imageData) return send(400, { error: 'Нет данных изображения' })
+ const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/)
+ if (!dataMatch) return send(400, { error: 'Неверный формат' })
+ const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
+ const ext = TYPES[dataMatch[1]]
+ if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` })
+ const buffer = Buffer.from(dataMatch[2], 'base64')
+ const folder = String(body.folder || '').trim()
+ const fileName = String(body.fileName || '').trim()
+ if (!folder) return send(400, { error: 'Не указана папка (дата письма)' })
+ if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' })
+ if (!fileName) return send(400, { error: 'Не указано имя файла' })
+ const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_')
+ const remoteDir = `${fc.remotePath}/${folder}`
+ const remoteFile = `${remoteDir}/${safeName}.${ext}`
+ const steps = []
+ try {
+ if (fc.protocol === 'sftp') {
+ steps.push('sftp: connecting')
+ const sftp = await connectSftp()
+ steps.push('sftp: mkdir ' + remoteDir)
+ await sftp.mkdir(remoteDir, true)
+ steps.push('sftp: uploading ' + remoteFile)
+ await sftp.put(buffer, remoteFile)
+ steps.push('sftp: done')
+ await sftp.end()
+ } else {
+ const client = await connectFtp()
+ await client.ensureDir(remoteDir)
+ const tmpFile = path.resolve(dataDir, `.ftp-tmp-${Date.now()}.${ext}`)
+ try {
+ fs.writeFileSync(tmpFile, buffer)
+ await client.uploadFrom(tmpFile, `${safeName}.${ext}`)
+ } finally {
+ try { fs.unlinkSync(tmpFile) } catch {}
+ client.close()
+ }
+ }
+ const publicUrl = `${fc.baseUrl}/${folder}/${safeName}.${ext}`
+ return send(200, { url: publicUrl, name: `${safeName}.${ext}`, steps })
+ } catch (e) {
+ return send(500, { error: 'upload_failed', details: e?.message || 'Ошибка загрузки', steps })
+ }
+ }
+
+ if (ftpAction === 'list') {
+ const body = await readBody(req)
+ const folder = String(body.folder || '').trim()
+ if (!folder) return send(400, { error: 'Не указана папка' })
+ if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' })
+ const remoteDir = `${fc.remotePath}/${folder}`
+ try {
+ let files = []
+ if (fc.protocol === 'sftp') {
+ const sftp = await connectSftp()
+ const exists = await sftp.exists(remoteDir)
+ if (exists) {
+ const listing = await sftp.list(remoteDir)
+ files = listing
+ .filter(f => f.type === '-' && /\.(png|jpe?g|gif|webp)$/i.test(f.name))
+ .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` }))
+ }
+ await sftp.end()
+ } else {
+ const client = await connectFtp()
+ try {
+ const listing = await client.list(remoteDir)
+ files = listing
+ .filter(f => f.type === 1 && /\.(png|jpe?g|gif|webp)$/i.test(f.name))
+ .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` }))
+ } catch { /* folder doesn't exist yet */ }
+ client.close()
+ }
+ return send(200, { files, folder })
+ } catch (e) {
+ return send(500, { error: 'list_failed', details: e?.message || 'Ошибка получения списка' })
+ }
+ }
+
+ if (ftpAction === 'delete') {
+ const body = await readBody(req)
+ const folder = String(body.folder || '').trim()
+ const fileName = String(body.fileName || '').trim()
+ if (!folder || !fileName) return send(400, { error: 'Не указана папка или файл' })
+ if (!/^[\w\-. а-яА-ЯёЁ]+$/i.test(fileName)) return send(400, { error: 'Недопустимое имя файла' })
+ if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' })
+ const remoteDir = `${fc.remotePath}/${folder}`
+ try {
+ if (fc.protocol === 'sftp') {
+ const sftp = await connectSftp()
+ await sftp.delete(`${remoteDir}/${fileName}`)
+ await sftp.end()
+ } else {
+ const client = await connectFtp()
+ await client.ensureDir(remoteDir)
+ await client.remove(fileName)
+ client.close()
+ }
+ return send(200, { ok: true })
+ } catch (e) {
+ return send(500, { error: 'delete_failed', details: e?.message || 'Ошибка удаления' })
+ }
+ }
+ }
+
+ // --- Yonote API ---
+ if (req.method === 'GET' && req.url === '/api/yonote/status') {
+ const config = readJson(configFile, {})
+ if (!config.yonote_token) return send(200, { configured: false })
+ try {
+ const client = getYonoteClient()
+ await client.documents.list({ type: ['database'] }, { offset: 0, limit: 1 })
+ return send(200, { configured: true, connected: true })
+ } catch (e) {
+ return send(200, { configured: true, connected: false, error: e?.message || 'Ошибка подключения' })
+ }
+ }
+
+ if (req.method === 'GET' && req.url?.startsWith('/api/yonote/databases')) {
+ const client = getYonoteClient()
+ if (!client) return send(401, { error: 'yonote_not_configured' })
+ try {
+ const result = await client.documents.list({ type: ['database'] })
+ const databases = (result.data || []).map(d => ({ id: d.id, title: d.title }))
+ return send(200, { databases })
+ } catch (e) {
+ return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка Yonote API' })
+ }
+ }
+
+ const yonotePropMatch = req.url?.match(/^\/api\/yonote\/database\/([^/]+)\/properties$/)
+ if (yonotePropMatch && req.method === 'GET') {
+ const dbId = decodeURIComponent(yonotePropMatch[1])
+ const client = getYonoteClient()
+ if (!client) return send(401, { error: 'yonote_not_configured' })
+ try {
+ const doc = await client.documents.get({ id: dbId })
+ const props = doc.data.properties || {}
+ const properties = Object.entries(props).map(([key, p]) => ({
+ id: p.id || key, title: p.title, type: p.type,
+ options: (p.options || []).map(o => ({ id: o.id, label: o.label, color: o.color })),
+ }))
+ return send(200, { properties })
+ } catch (e) {
+ return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка получения свойств' })
+ }
+ }
+
+ const yonoteRowsMatch = req.url?.match(/^\/api\/yonote\/database\/([^/]+)\/rows$/)
+ if (yonoteRowsMatch && req.method === 'GET') {
+ const dbId = decodeURIComponent(yonoteRowsMatch[1])
+ const client = getYonoteClient()
+ if (!client) return send(401, { error: 'yonote_not_configured' })
+ try {
+ const allRows = []
+ let offset = 0
+ const limit = 100
+ while (true) {
+ const result = await client.documents.list({ parentDocumentId: dbId, type: ['row'], sort: 'updatedAt', direction: 'DESC' }, { offset, limit })
+ const rows = result.data || []
+ allRows.push(...rows)
+ if (rows.length < limit || allRows.length >= 500) break
+ offset += limit
+ }
+ return send(200, { rows: allRows.map(r => ({ id: r.id, title: r.title, values: r.values || {} })) })
+ } catch (e) {
+ return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка чтения строк' })
+ }
+ }
+
+ if (req.method === 'POST' && req.url === '/api/yonote/row/update') {
+ const body = await readBody(req)
+ const { rowId, values } = body
+ if (!rowId || !values) return send(400, { error: 'missing_params' })
+ const client = getYonoteClient()
+ if (!client) return send(401, { error: 'yonote_not_configured' })
+ try {
+ await client.documents.update({ id: rowId, values })
+ return send(200, { ok: true })
+ } catch (e) {
+ return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка обновления строки' })
+ }
+ }
+
+ // --- Stats ---
+ const statsMatch = req.url.match(/^\/api\/project\/([^/]+)\/stats$/)
+ if (statsMatch) {
+ const name = decodeURIComponent(statsMatch[1])
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ const statsPath = path.resolve(dir, 'stats.json')
+ if (req.method === 'GET') {
+ return send(200, { entries: readJson(statsPath, []) })
+ }
+ if (req.method === 'POST') {
+ const body = await readBody(req)
+ const entry = body?.entry
+ if (!entry?.letterId) return send(400, { error: 'missing letterId' })
+ entry.userId = req.user?.id || ''
+ entry.userName = req.user?.name || ''
+ const entries = readJson(statsPath, [])
+ const idx = entries.findIndex(e => e.letterId === entry.letterId && e.userId === entry.userId)
+ if (idx >= 0) {
+ entries[idx] = { ...entries[idx], ...entry }
+ } else {
+ entries.push(entry)
+ }
+ fs.writeFileSync(statsPath, JSON.stringify(entries, null, 2))
+ return send(200, { ok: true })
+ }
+ }
+ const allStatsMatch = req.method === 'GET' && req.url === '/api/stats'
+ if (allStatsMatch) {
+ const HIDDEN_DIRS = new Set(['uploads', 'node_modules', '.git', '_system'])
+ const projects = fs.readdirSync(dataDir, { withFileTypes: true })
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name))
+ .map(d => d.name)
+ const all = {}
+ for (const p of projects) {
+ const sp = path.resolve(dataDir, p, 'stats.json')
+ const entries = readJson(sp, [])
+ if (entries.length) all[p] = entries
+ }
+ return send(200, { stats: all })
+ }
+
+ const match = req.url.match(/^\/api\/project\/([^/]+)(\/(block|block-custom|settings|draft|presets))?$/)
+ if (!match) return send(404, { error: 'not_found' })
+ const name = decodeURIComponent(match[1])
+ const action = match[3]
+ const dir = getProjectDir(name)
+ if (!dir) return send(400, { error: 'invalid_project_name' })
+ ensureDir(dir)
+
+ if (req.method === 'GET' && !action) {
+ const blockPath = path.resolve(dir, 'block.pug')
+ const meta = readJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' })
+ const settings = readJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} })
+ // Маскируем FTP-пароль
+ if (settings.ftpConfig?.password) {
+ settings.ftpConfig = { ...settings.ftpConfig, password: '', hasPassword: true }
+ }
+ const userDraftFile = path.resolve(dir, 'drafts', `${req.user?.id || '_default'}.json`)
+ const draft = readJson(userDraftFile, [])
+ const presets = readJson(path.resolve(dir, 'presets.json'), [])
+ const letters = readJson(getUserLettersFile(dir), { list: [], currentId: '' })
+ const notes = readJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' })
+ const blockText = fs.existsSync(blockPath) ? fs.readFileSync(blockPath, 'utf-8') : ''
+ return send(200, { name, meta, settings, draft, presets, letters, notes, blockText })
+ }
+
+ if (req.method === 'PUT' && action) {
+ const body = await readBody(req)
+ if (action === 'block') {
+ fs.writeFileSync(path.resolve(dir, 'block.pug'), body.blockText || '', 'utf-8')
+ writeJson(path.resolve(dir, 'meta.json'), { sourceName: body.sourceName || 'Block.pug' })
+ return send(200, { ok: true })
+ }
+ if (action === 'block-custom') {
+ fs.writeFileSync(path.resolve(dir, 'block-custom.pug'), body.content || '', 'utf-8')
+ return send(200, { ok: true })
+ }
+ if (action === 'settings') {
+ if (req.user?.role !== 'admin') return send(403, { error: 'Только admin может менять настройки проекта' })
+ const incoming = body || { globalSpacing: 40, blocks: {} }
+ // Сохраняем существующий FTP-пароль если не передан новый
+ if (incoming.ftpConfig && !incoming.ftpConfig.password) {
+ const existing = readJson(path.resolve(dir, 'settings.json'), {})
+ if (existing.ftpConfig?.password) {
+ incoming.ftpConfig.password = existing.ftpConfig.password
+ }
+ }
+ writeJson(path.resolve(dir, 'settings.json'), incoming)
+ return send(200, { ok: true })
+ }
+ if (action === 'draft') {
+ const draftsDir = path.resolve(dir, 'drafts')
+ ensureDir(draftsDir)
+ writeJson(path.resolve(draftsDir, `${req.user?.id || '_default'}.json`), body || [])
+ return send(200, { ok: true })
+ }
+ if (action === 'presets') {
+ writeJson(path.resolve(dir, 'presets.json'), body || [])
+ return send(200, { ok: true })
+ }
+ }
+
+ return send(405, { error: 'method_not_allowed' })
+ }
+ },
+})
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [svelte(), apiPlugin()],
+ server: {
+ allowedHosts: ['va.aspekter.ru'],
+ watch: {
+ usePolling: true,
+ interval: 300,
+ },
+ proxy: {
+ '/typograf': {
+ target: 'http://typograf.artlebedev.ru',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/typograf/, ''),
+ },
+ '/speller': {
+ target: 'https://speller.yandex.net',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/speller/, ''),
+ },
+ },
+ },
+})