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-конструктор
+

Письма, которые
продают.
Без кода.

+

+ Единый инструмент для маркетолога: план → конструктор → проверка → + экспорт. Всё в одном интерфейсе, без переключений между сервисами. +

+ +
+
+
+
+
+
+
+
+
+
Блоки
+
Баннер
+
4 товара
+
6 товаров
+
Разделитель
+
Текст
+
Кнопка
+
+
+
+
+
PUG
+
Превью
+
HTML
+
+
+
Жен
+
Муж
+
+
+
BANNER
+
+
Платье
18 990 ₽
+
Блузка
12 490 ₽
+
Жакет
24 990 ₽
+
Юбка
9 490 ₽
+
+
+
Копировать HTML
+
↓ Скачать
+
+
+
+
+
+
+ + +
+
+
Конструктор
+

Собери письмо
из готовых блоков

+

Drag & drop, поиск блоков, collapse, история — всё для быстрой сборки без кода.

+ +
+
+
+
Блоки и их управление
+
Добавляй блоки через поиск, меняй порядок перетаскиванием или стрелками. Сворачивай ненужные блоки — рабочая область остаётся чистой. Переиспользуй любимые блоки через Quick Blocks на панели.
+
Drag & DropПоиск блоковCollapseQuick Blocks
+
+
+
📦
+
Пресеты сборок
+
Сохрани текущую структуру письма как пресет — и применяй к следующим кампаниям одним кликом. Поиск, сортировка, удаление. Пресеты хранятся на сервере и доступны всей команде.
+
+
📧
Баннер + 4 товара + кнопка
12.03
+
📧
Sale: баннер + 6 товаров × 2
05.03
+
📧
Welcome-серия
18.02
+
+
+
+
🕐
+
История изменений
+
Каждое сохранение создаёт снимок. Откати письмо к любой точке в истории — без потери текущей версии. Просматривай все снимки с датой и временем.
+
+
14:32
Добавлен блок «4 товара»
↩ Откат
+
14:18
Изменён текст баннера
↩ Откат
+
13:55
Создано письмо
↩ Откат
+
+
+
+
💾
+
Автосохранение и Ctrl+S
+
Изменения сохраняются автоматически через 600мс после остановки. Индикатор-точка в интерфейсе показывает статус: сохраняется / сохранено / ошибка. Принудительное сохранение — Ctrl+S.
+
АвтосохранениеCtrl+SCtrl+Z — UndoCtrl+G — Рендер
+
+
+
+
+ + +
+
+
Редактор текста
+

Текст,
который выглядит правильно

+

Встроенные инструменты форматирования — типограф, жирный, переносы — прямо в интерфейсе конструктора.

+ +
+
+
    +
  • Типограф — автоматически исправляет кавычки, тире, пробелы по правилам русской типографики (SOAP API)
  • +
  • Жирный — оборачивает выделенный текст в span с font-weight: 700
  • +
  • Авто-перенос — умно разбивает текст по ширине блока, предотвращает висячие слова
  • +
  • Перенос строки и буллет — быстрая вставка <br> и • кнопками
  • +
  • Вставка ссылки с настраиваемым шаблоном UTM-меток
  • +
  • Shift+Enter — ручной перенос строки прямо в поле редактирования
  • +
+
+
Тф
+
B
+
+
+
↵ Авто
+
🔗 Ссылка
+
+
+
+
+
⚡ Quick Edit — редактирование в превью
+
+

Кликни на любой элемент в превью — появится плавающий попап для быстрого редактирования прямо на месте. Без поиска поля в списке блоков.

+
+
Текст баннера
+ +
+
Отмена
+
Сохранить
+
+
+
+
+
+
+
+
+ + +
+
+
Предпросмотр
+

Три режима.
Один клик.

+

Превью, HTML-код и Pug-исходник — в правой панели. Рендер кэшируется по хэшу контента — повторный просмотр мгновенный.

+ +
+
+
Режим 1
+
Живое превью
+
Отрендеренное письмо в iframe. Масштаб 40–100% — меняй ползунком, значение сохраняется в профиле. Рендер запускается автоматически или по Ctrl+G.
+
+
+
PUG
+
ПРЕВЬЮ
+
HTML
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Режим 2
+
HTML-код
+
Готовый HTML для вставки в ESP. Копируй в буфер одной кнопкой — с нормализацией разметки. Или скачивай как файл.
+
+ Копировать HTML + Скачать .html + Нормализация +
+
+
+
Режим 3
+
Pug-исходник
+
Собранный Pug-код письма — для версионирования и архива. Копируй или скачивай. Можно импортировать обратно в другой проект.
+
+ Копировать PUG + Скачать .pug + Импорт блоков +
+
+
+
+
+ + +
+
+
+
+
Сегментация
+

Одно письмо —
две версии

+

Женская и мужская подборка в одном файле. Переключение — одной кнопкой, рендер — мгновенно.

+
    +
  • Кнопки Ж / М — рендер с соответствующим хедером и футером
  • +
  • Кнопка — переставляет блоки относительно блока-разделителя
  • +
  • Каждый блок получает сегмент: Общий / Женский / Мужской
  • +
  • Пути хедера/футера настраиваются в настройках проекта
  • +
  • Версии Ж и М кэшируются отдельно — скорость не страдает
  • +
+
+
♀ Женская версия
+
+
♂ Мужская версия
+
+
+
+
+
📦 Пул ID из плана
+
+

Вставь список ID товаров — система автоматически распределит их по блокам (3–4 ID на блок). Женские ID — первыми, мужские — следом. Счётчик показывает использованные/всего.

+
+
Пул ID8 / 20 использовано
+
+
Блок 1 (Жен)
112233, 445566, 778899, 001122
+
Блок 2 (Муж)
334455, 667788, 990011, 223344
+
Осталось
12 ID в очереди
+
+
+
+
+
+
+
+
+ + +
+
+
Контроль качества
+

Ни один
«нет в наличии» не пройдёт

+

Система парсит 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.03Sale до –40%В РАБОТЕ
+
22.03Новые поступленияПЛАН
+
25.03Тренды сезонаПЛАН
+
28.03Бренд-фокус: Max MaraПЛАН
+
+
+
+
+
+
+
+ + +
+
+
+
+
Медиабиблиотека
+

FTP-галерея
прямо в браузере

+

Загружай изображения для писем без FTP-клиентов. Пакетная загрузка, просмотр, копирование URL — не выходя из конструктора.

+
    +
  • Пакетная загрузка — несколько файлов одним выбором
  • +
  • Папки организованы по дате письма — автоматически
  • +
  • Клик по миниатюре — копирует URL в буфер
  • +
  • Прогресс загрузки: N из M / процент
  • +
  • Удаление файлов с подтверждением
  • +
  • Поддержка FTP и SFTP
  • +
+
+
+
+
🖼 FTP Галерея — 03-14
+
+
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
🖼
+
+
+ Загрузить файлы (3/5 загружается...)
+
+
+
+
+
+
+ + +
+
+
Специальные блоки
+

Сертификаты
и расширенные блоки

+

Специализированный редактор сертификатов — отдельный тип контента с перетаскиванием, типографом и форматированием.

+ +
+
+
🏆
+
Редактор сертификатов
+
Отдельный режим для блоков-сертификатов. Добавляй строки, меняй их порядок перетаскиванием. Жирный, перенос, буллеты, Типограф — всё как в основном редакторе. Готовый сертификат сохраняется в письмо.
+
+
Дорогой клиент, дарим вам
+
Сертификат на 5 000 ₽
+
действителен до 31 декабря 2026
+
+
+
+
⚙️
+
Опции товарных блоков
+
Для каждого блока с товарами — набор переключаемых опций (скидка, новинка, хит и т.д.). Включай и выключай опцию для конкретного блока или сразу для всех. Визуальный индикатор состояния: вкл / частично / выкл.
+
СкидкаНовинкаХитЭксклюзив+ Свои опции
+
+
+
+
+ + +
+
+
Процесс
+

От плана до
готового HTML

+

Три шага без переключений между инструментами.

+
+
+
01
+
Открой план
+
Перейди в редакционный календарь. Найди нужную рассылку — дата, тема и прехедер уже заполнены. Нажми «Собрать» — письмо откроется в конструкторе с нужными данными.
+
+
+
02
+
Собери письмо
+
Добавляй блоки, редактируй тексты, загружай баннеры через FTP-галерею. Вставь ID из пула — распределятся сами. Переключи гендерную версию — убедись, что обе выглядят идеально.
+
+
+
03
+
Проверь и отправь
+
Проверь наличие товаров, орфографию и ссылки. Всё чисто — копируй HTML и вставляй в платформу. Обнови статус «Отправлено» прямо из интерфейса.
+
+
+
+
+ + +
+
+
Платформа
+

Настраивается
под проект

+

Детальные настройки для каждого проекта, ролевой доступ и статистика трудозатрат.

+ +
+
+
Настройки
+
Гибкая конфигурация
+
Логотип, акцентный цвет, отступы. Для каждого блока — шаблон, видимость полей, подписи. Редактор Pug-частей (хедер/футер) прямо из интерфейса. Шаблон ссылок с UTM, авто-нумерация изображений.
+
+
+
Авторизация
+
Безопасность
+
scrypt-хэши паролей. Сессии 7 дней, хранятся на сервере (переживают перезапуск). Тема, zoom, активная страница — сохраняются в профиле. Настройки доступны сразу на любом устройстве.
+
+
+
Команда
+
Управление пользователями
+
Создавай пользователей, назначай роли (admin / user) и проекты. Каждый видит только свои проекты. Смена пароля и настроек профиля — из интерфейса.
+
+
s.zotov
admin
сегодня
Ред.
+
marketing
user
вчера
Ред.
+
+
+
+
Аналитика
+
Статистика времени
+
Трекер фиксирует время работы над каждым письмом. Статусы: черновик, в работе, отправлено. Агрегированная статистика по месяцам — чтобы планировать нагрузку команды.
+
+
47
Писем в марте
+
3.2ч
Среднее время
+
98%
Отправлено
+
0
Ошибок
+
+
+
+
+
+ + +
+
+

Готовы делать
письма быстрее?

+

Войдите в систему и начните прямо сейчас.

+ Войти в 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} + + + + {#if saveErrorMsg} + + Ошибка сохранения + + + {/if} + + {#if activePage === 'builder'} + + + + {#if productOptionsList.length > 0} +
    + {#each productOptionsList as option} + + {/each} +
    + {/if} + {/if} +
    + {#if currentLetterName} +
    + {#if isEditingLetterDate} + { + if (event.key === 'Enter') commitEditLetterDate() + if (event.key === 'Escape') cancelEditLetterDate() + }} + /> + {:else} + + {formatLetterDate(currentLetterDate)} + + {/if} + + {#if isEditingLetterName} + { + if (event.key === 'Enter') commitEditLetterName() + if (event.key === 'Escape') cancelEditLetterName() + }} + /> + {:else} + + {currentLetterTitle} + + {/if} +
    + {/if} +
    +
    + + + +
    + +
    +
    +
    + + {#if activePage === 'builder'} + {#if loadError} +
    {loadError}
    + {/if} + +
    +
    + {#if idPoolOpen} +
    +
    +
    + Пул ID + {#if idPoolQueue.length} + {idPoolQueue.filter(e => e.usedBy).length}/{idPoolQueue.length} + {/if} +
    +
    + + {#if assemblyInfo} + + {/if} + +
    +
    + + {#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} + + {/each} + +
    + {/if} + {:else} +
    + +
    +
    + {#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} + > +
    +
    { toggleBlock(block.id); setActiveBlock(block.id, true) }} + on:keydown={(event) => event.key === 'Enter' && toggleBlock(block.id)} + > + + {String(index + 1).padStart(2, '0')} + {block.name} +
    +
    +
    + + + + {#if block.name === 'Разделитель'} + + {/if} +
    + + + +
    +
    + + {#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'} +
    + + + {#if block.name === 'Текст' && fieldIndex === 0} + + {/if} + {#if block.name === 'Текст' && field.removable} + + {/if} + {#if isListBlock(block.name) && fieldIndex === 0} + + {/if} + {#if isListBlock(block.name) && field.removable} + + {/if} + + + + + + updateLinkDraft(key, event.target.value)} + /> + + + {#if _sh} + + + + + + {/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} + + {/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} + + {/each} + +
    + {/if} +
    + {/if} +
    + +
    +
    +
    +
    + {outputPanelMode === 'pug' ? 'PUG' : outputPanelMode === 'html' ? 'HTML' : 'Предпросмотр'} +
    +
    + {outputPanelMode === 'pug' + ? 'Итоговый файл' + : outputPanelMode === 'html' + ? 'Сгенерированный index.html' + : previewGeneratedAt + ? new Date(previewGeneratedAt).toLocaleString('ru-RU') + : 'Готовое письмо после генерации'} +
    +
    +
    + + + + + {#if outputPanelMode === 'pug'} + + + {:else if outputPanelMode === 'html'} + + + + + {:else} + + {#if previewHtml} + + + + {/if} + {/if} +
    +
    + {#if linkCheckActive && linkCheckResults.length} + + {/if} + {#if outputPanelMode === 'pug'} + + {:else if outputPanelMode === 'html'} + + {:else} +
    + {#if previewError} +
    {previewError}
    + {/if} + {#if unavailableProducts.length > 0} +
    +
    unavailableCollapsed = !unavailableCollapsed} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (unavailableCollapsed = !unavailableCollapsed)}> + ⚠ Нет в наличии ({unavailableProducts.length}){#if feedSyncedAt} · фид {new Date(feedSyncedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}{/if} + {unavailableCollapsed ? '▸' : '▾'} +
    + {#if !unavailableCollapsed} + {#each unavailableProducts as prod} +
    + {prod.name || ''} ID {prod.id}{#if prod.price} · {Number(prod.price).toLocaleString('ru-RU')} руб.{/if} + +
    + {/each} + {/if} +
    + {#if suggestForProduct} +
    +
    + Замены для ID {suggestForProduct} + +
    + + {#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} + + {:else if !previewLoading && !previewError} +
    Нажми 🔄, чтобы собрать и увидеть письмо.
    + {/if} +
    + {/if} +
    +
    + {:else if activePage === 'settings'} +
    +
    +
    Настройки
    +
    Проект, блоки и интеграции
    +
    +
    + +
    +
    + + + + + + + {#if currentUser?.role === 'admin'} + + {/if} +
    +
    + +
    +
    + {#if settingsTab === 'project'} +
    +
    +
    +
    Текущий проект
    +
    Настрой отображение и цвета для активного проекта.
    + + + + +
    Стиль гиперссылок
    +
    Шаблон для кнопки «A» в текстовых полях. Используй {`{url}`} и {`{text}`} как плейсхолдеры.
    + +
    Пусто = стиль по умолчанию. Пример: {`{text}`}
    +
    +
    +
    Источник блоков
    +
    Загрузи файл block.pug для текущего проекта.
    +
    + + +
    +
    Текущий файл: {sourceName}
    +
    +
    +
    +
    +
    Отступы
    +
    Глобальный отступ применяется ко всем блокам, если не задано иначе.
    + +
    + Отступы не добавляются автоматически после блоков «Отступ 20» и «Отступ 40». +
    +
    +
    +
    Быстрые блоки
    +
    Настрой список кнопок быстрого добавления в конструкторе.
    +
    + + +
    + {#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} + + {/each} + + + {/each} +
    +
    +
    +
    +
    +
    Аккаунт
    +
    {currentUser?.name || currentUser?.login} ({currentUser?.role})
    + +
    + {:else if settingsTab === 'blocks'} +
    +
    Поля блока
    +
    Выбери блок и настрой, какие поля показывать и как их подписывать.
    + +
    + {#if baseSchema.length === 0} +

    Поля не найдены для текущего шаблона.

    + {:else} + {#each baseSchema as field, fieldIndex (fieldIndex)} + {@const sig = fieldSignature(field)} +
    + + + updateSettingsField(settingsBlockName, sig, { label: event.target.value }) + } + /> +
    + {/each} + {/if} +
    +
    + +
    +
    Опции товаров
    +
    Добавь опции, которые будут доступны в товарных блоках и в шапке.
    +
    + {#each settings.productOptions || [] as option, index} +
    + updateProductOption(index, { label: event.target.value })} + /> + updateProductOption(index, { code: event.target.value })} + /> + + +
    + {/each} + +
    +
    + +
    +
    Правила mixin-полей
    +
    Задай, какие аргументы mixin редактируются в конструкторе.
    +
    + {#each settings.mixinRules || [] as rule, index} +
    + updateMixinRule(index, { mixin: event.target.value })} + /> + updateMixinRule(index, { argIndex: Number(event.target.value || 1) - 1 })} + /> + + updateMixinRule(index, { label: event.target.value })} + /> + +
    + {/each} + +
    +
    + +
    +
    Пользовательские блоки
    +
    Создай свои блоки из Pug-кода. Поля определяются автоматически.
    + {#each settings.customBlocks || [] as cb (cb.id)} +
    +
    + {#if editingCustomBlockId === cb.id} + updateCustomBlock(cb.id, { name: e.target.value })} + /> + {:else} +
    {cb.name}
    + {/if} +
    + + +
    +
    + {#if editingCustomBlockId === cb.id} + + {@const previewSchema = buildBaseSchema(cb.content, cb.name, settings?.mixinRules)} + {#if previewSchema.length > 0} +
    +
    Полей: {previewSchema.length}
    + {#each previewSchema as field, fi (fi)} + {@const sig = fieldSignature(field)} +
    + + updateSettingsField(cb.name, sig, { label: e.target.value })} + /> +
    + {/each} +
    + {:else} +
    Поля не найдены
    + {/if} + {/if} +
    + {/each} +
    + + + {#if customBlockError} +
    {customBlockError}
    + {/if} + +
    +
    + {:else if settingsTab === 'templates'} +
    + +
    + + + +
    + {#if allBlocks.length > 8} + + {/if} +
    + {#each filteredTemplateBlocks as block, index} +
    +
    + +
    toggleTemplateCard(block.name)}> + {index + 1}. {block.name} + {#if getBlockSetting(block.name).template} + + {/if} + {#if block.isCustom} + свой + {/if} +
    +
    + {#if templatesExpanded.has(block.name)} + +
    + + {#if getBlockSetting(block.name).spacingEnabled ?? true} + updateSettingsBlockSpacing(block.name, event.target.value)} + /> + {/if} + {#if getBlockSetting(block.name).template} + + {/if} + + +
    + {/if} +
    + {/each} + {#if filteredTemplateBlocks.length === 0} +
    Ничего не найдено
    + {/if} +
    + {:else if settingsTab === 'parts'} +
    +
    Редактор хедера / футера
    +
    Редактируй тексты кнопок и ссылки в pug-файлах хедера и футера.
    + + {#if partsEditorLoading} +
    Загрузка...
    + {:else if partsEditorFields.length > 0} +
    + {#each ['button', 'link', 'html-link', 'html-text'] as groupType} + {#if partsEditorFields.some(f => f.type === groupType)} +
    + {groupType === 'button' ? 'Кнопки' : groupType === 'html-text' ? 'Тексты' : 'Ссылки'} +
    + {#each partsEditorFields as field, i} + {#if field.type === groupType} +
    + {#if field.type === 'html-text'} + + {:else} + + + {/if} +
    + {/if} + {/each} + {/if} + {/each} +
    +
    + + {#if partsEditorSuccess}{partsEditorSuccess}{/if} + {#if partsEditorError}{partsEditorError}{/if} +
    + {:else if partsEditorFile} +
    Редактируемых полей не найдено.
    + {/if} +
    + {:else if settingsTab === 'integrations'} +
    +
    Yonote API
    +
    + API-токен из Yonote (Настройки → API). Используется для чтения и записи в базу данных Yonote. +
    + + + {#if yonoteConfigError}
    {yonoteConfigError}
    {/if} +
    + + {#if yonoteStatus.configured} + {yonoteStatus.connected ? 'Подключён' : 'Не подключён'} + {/if} + {#if yonoteConfigStatus}{yonoteConfigStatus}{/if} +
    +
    +
    +
    База данных Yonote для «{currentProject}»
    +
    Выбери базу данных и сопоставь свойства для страницы «План».
    + + {#if yonoteDbProperties.length > 0 || yonotePropsLoading} +
    Сопоставь ключевые поля. Все остальные свойства из Yonote будут показаны автоматически.
    +
    + + + + +
    + {#if yonoteStatusOptions.length > 0} + + {/if} +
    + Свойства базы: {yonoteDbProperties.map(p => p.title).join(', ')} +
    + {/if} +
    + + +
    + {#if yonoteTestResult} + {@const isOk = yonoteTestResult.startsWith('ok:')} +
    {yonoteTestResult.replace(/^(ok|error):/, '')}
    + {/if} +
    +
    +
    FTP/SFTP загрузка для «{currentProject}»
    +
    Загружайте изображения на FTP/SFTP сервер. Папка по дате письма создаётся автоматически.
    + +
    + + +
    +
    + + +
    + + +
    Результат: {ftpBaseUrl || 'https://...'}/{getLetterDateFolder() || 'DD-MM'}/имя.png
    +
    + + +
    + {#if ftpTestResult} + {@const isOk = ftpTestResult.startsWith('ok:')} +
    + {ftpTestResult.replace(/^(ok|error):/, '')} +
    + {/if} +
    +
    +
    Сервис рассылок
    +
    Кнопка под карточкой сборки для перехода в сервис
    + + +
    + +
    +
    + +
    +
    Гендерные пути
    +
    Include-пути для header/footer по гендеру. Используются при переключении Ж/М.
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    Автонумерация картинок
    +
    Базовый URL и расширение для автоматической нумерации картинок в блоках.
    +
    + + +
    +
    + +
    +
    + +
    +
    Фид товаров
    +
    URL YML-фида каталога. Товары подставятся в превью вместо Mindbox-тегов.
    + +
    + + {#if feedUrlDraft.trim()} + + {/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} + + + + + + + + {/each} + +
    ЛогинИмяРольПроекты
    {user.login}{user.name} + + + {PROJECT_NAME} + + {#if user.id !== currentUser.id} + + {/if} +
    +
    +
    Новый пользователь
    +
    + + + + + +
    +
    + {/if} +
    + {:else if settingsTab === 'certificate'} +
    +
    Сертификат
    +
    Группа блоков, которая добавляется в конец письма. Включается/выключается в конструкторе для каждого письма отдельно.
    + +
    + + {#if certBlocks.length > 0} + {certBlocks.length} блок(а/ов) + + {/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} + + +
    +
    +
    +
    + {#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} +
    +
    + {String(dayGroup.date.getDate()).padStart(2, '0')} +
    + {dayGroup.date.toLocaleString('ru-RU', { month: 'long' })} + {dayGroup.date.toLocaleString('ru-RU', { weekday: 'short' })} +
    + {dayGroup.rows.length} +
    + {#each dayGroup.rows as row} +
    + {row.project} +
    +
    +
    {row.subject}
    +
    + {#if planEditingRowId === planRowId(row)} + + {#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}{row.preheader.length > 70 ? row.preheader.slice(0, 70) + '…' : row.preheader}{/if} + {#each (row.extra || []).filter(e => e.link || isUrl(e.value)) as ext} + {ext.label} + {/each} +
    + {#if (row.extra || []).some(e => !e.link && !isUrl(e.value))} +
    + {#each (row.extra || []).filter(e => !e.link && !isUrl(e.value)) as ext} + {ext.value} + {/each} +
    + {/if} +
    + +
    + {/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)} +
    +
    + {monthLabel} + {entries.length} писем, {Math.floor(totalMin / 60)}ч {totalMin % 60}м +
    + + + + + + + + + + + + {#each entries.sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || '')) as entry} + + + + + + + + {/each} + +
    ПроектПисьмоТегВремяСтатус
    {entry.project}{entry.letterName}{entry.tag || ''}{entry.minutes || 0} мин{entry.status || ''}
    +
    + {/each} + {/if} +
    + {:else} +
    +
    +
    Письма
    +
    Сохранённые письма
    +
    +
    + +
    + {#if presets.length > 0} +
    +
    Пресеты
    +
    + {#each presets as preset} +
    + + +
    + {/each} +
    +
    + {/if} + {#if letters.length > 8} + + {/if} + {#if filteredLetters.length === 0} +
    {letterSearch ? 'Ничего не найдено.' : 'Писем пока нет.'}
    + {:else} +
    + {#each filteredLetters as letter} +
    +
    +
    {letter.name}
    +
    {formatPresetDate(letter.updatedAt || letter.createdAt)}
    +
    +
    + + + + +
    + {#if showHistoryFor?.id === letter.id} +
    + {#if historyLoading} +
    Загрузка…
    + {:else if historySnapshots.length === 0} +
    История пуста
    + {:else} + {#each historySnapshots as snap} + + {/each} + {/if} +
    + {/if} +
    + {/each} +
    + {/if} +
    + {/if} +
    + + {#if showLetterModal} + + {/if} + + {#if showPresetModal} + + {/if} + + {#if showStartupModal} + + {/if} + + {#if showFtpGalleryModal} + + {/if} + + {#if showNoteModal} + + {/if} +{#if quickEditVisible && quickEditBlock && quickEditField} +
    +
    + {quickEditBlock.name} — {quickEditField.label} + +
    + +
    +{/if} +
    + +{#if toasts.length > 0} +
    + {#each toasts as toast (toast.id)} +
    +
    {toast.message}
    + +
    + {/each} +
    +{/if} + +{#if suggestHoverItem} +
    + {#if suggestHoverItem.image}{/if} +
    +
    {suggestHoverItem.name}
    + {#if suggestHoverItem.vendor}
    {suggestHoverItem.vendor}
    {/if} + {#if suggestHoverItem.color}
    {suggestHoverItem.color}
    {/if} + {#if suggestHoverItem.gender}
    {suggestHoverItem.gender}
    {/if} +
    {suggestHoverItem.price ? `${Number(suggestHoverItem.price).toLocaleString('ru-RU')} руб.` : ''}
    +
    ID {suggestHoverItem.id}
    +
    +
    +{/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 @@ + + + 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/, ''), + }, + }, + }, +})