Sync prod working changes: new tools (regex/favicon), queue/session/storage/ws libs, video/compress/pdf/redirects updates

This commit is contained in:
treamz 2026-07-24 16:45:55 +03:00
parent 306101649f
commit 87f8c55da0
48 changed files with 4108 additions and 1282 deletions

111
DOCS.md
View File

@ -35,13 +35,17 @@
| Среда выполнения | Node.js 20 |
| Веб-фреймворк | Express 5 |
| База данных | MariaDB 10.11 |
| Хранение сессий | express-mysql-session (MariaDB) |
| Очередь задач | better-sqlite3 (SQLite WAL, `/mnt/webdata/storage/jobs.db`) |
| Обработка изображений | Sharp 0.34 |
| Обработка видео | FFmpeg (системный), ffprobe |
| Обработка видео | FFmpeg 5.1.8 (libx264, libvpx, libmp3lame), ffprobe |
| Обработка PDF | pdf-lib, pdf-parse, Ghostscript (системный) |
| Парсинг HTML | @mozilla/readability, jsdom |
| Авторизация | express-session, bcrypt |
| Панель администратора | AdminJS 7 + @adminjs/sequelize |
| Стили | Tailwind CSS (CDN), кастомный shared.css |
| Стили | Tailwind CSS 3 (CLI build, `vendor/tailwind.min.css`) |
| WebSocket | ws (реалтайм прогресс конвертации) |
| Логирование | pino (структурированный JSON) |
| Процесс-менеджер | PM2 |
### Сервер
@ -69,7 +73,11 @@
│ ├── auth.js # Middleware авторизации: список публичных путей, редиректы
│ ├── admin.js # Настройка AdminJS: Sequelize модели, ресурсы, монтирование
│ ├── db.js # Пул соединений MariaDB (mysql2/promise), хелперы для users
│ ├── logger.js # Логгер: stdout + запись в compress.log через logToFile
│ ├── logger.js # Логгер pino: структурированный JSON + logToFile
│ ├── queue.js # Очередь задач на SQLite (better-sqlite3, WAL mode)
│ ├── session.js # Persistent sessions (express-mysql-session → MariaDB)
│ ├── storage.js # Единые пути хранения: /mnt/webdata/storage/{uploads,results}
│ ├── ws.js # WebSocket сервер (ws) — реалтайм прогресс конвертации
│ └── ssrf.js # Защита от SSRF: проверка IP-диапазонов, DNS-резолвинг
├── routes/
@ -650,6 +658,10 @@ session({
### Rate limiting для auth
Rate limit работает per-user: `keyGenerator` использует `req.session.user.id` для авторизованных пользователей, `req.ip` для анонимных. Это предотвращает ситуацию, когда один бот исчерпывает лимит для всех.
15 минут, 10 попыток (применяется к `POST /auth/login` и `POST /auth/register`).
---
@ -961,12 +973,25 @@ session({
```js
helmet({
contentSecurityPolicy: false, // отключено — Tailwind CDN требует inline-скриптов
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://mc.yandex.ru"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://mc.yandex.ru"],
connectSrc: ["'self'", "wss:", "ws:"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
})
```
Остальные политики helmet включены: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
CSP включён. `unsafe-inline` необходим для inline `<script>` и `<style>` блоков UI. Остальные политики helmet включены: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
### Защита от SSRF (`lib/ssrf.js`)
@ -999,11 +1024,14 @@ helmet({
### Сессия
- Хранение: MariaDB через `express-mysql-session` (таблица `sessions`)
- Сессии переживают рестарт PM2 — пользователи остаются залогиненными
- `httpOnly: true` — недоступно JavaScript
- `secure: true` в production — только по HTTPS
- `sameSite: 'lax'` — защита от CSRF
- Session regeneration при логине — защита от session fixation attack
- Logout: `req.session.destroy()`
- Автоочистка: `checkExpirationInterval: 900000` (15 мин), `expiration: 86400000` (24ч)
### Санитизация ввода
@ -1136,25 +1164,40 @@ Let's Encrypt через certbot. Сертификат:
"status": "ok",
"uptime": 86400,
"memory": {
"rss": "124MB",
"heap": "67/128MB"
"rss": "302MB",
"heap": "86/91MB",
"system": "37%"
},
"node": "v20.18.0",
"cpu": {
"load1m": "0.00",
"load5m": "0.00",
"load15m": "0.05",
"cores": 4
},
"queue": {
"stats": { "done": 5, "processing": 1 },
"active": 1,
"pending": 0
},
"ws": {
"connections": 2
},
"node": "v20.19.5",
"pid": 12345
}
```
Uptime считается от момента запуска процесса Node.js (не PM2).
Расширенный health: системная память, CPU load, глубина очереди задач, активные WebSocket подключения.
### Логирование
**stdout/stderr** (`lib/logger.js`):
- Формат: `[ISO timestamp] [LEVEL] message {meta JSON}`
- Уровни: `error`, `warn`, `info`, `debug`
- Уровень контролируется через `LOG_LEVEL` в `.env`
- PM2 перенаправляет в файлы логов
**pino** (`lib/logger.js`):
- Формат: структурированный JSON (`{"level":30,"time":...,"msg":"..."}`)
- Уровни: `error`, `warn`, `info`, `debug` (контролируется через `LOG_LEVEL`)
- API совместим: `log.info(msg, meta)`, `log.error(msg, meta)`, `log.logToFile(text)`
- PM2 перенаправляет stdout/stderr в файлы логов
**compress.log** — отдельный файл для операций сжатия изображений и парсинга:
**logToFile** — append-запись в файл для операций сжатия/парсинга:
- Формат: `[ISO timestamp] [RU] 1.2.3.4 compress: file.jpg 800x600 format=webp q=80 (234KB)`
- Включает страну из GeoIP, IP, имя файла, параметры, размер
@ -1162,32 +1205,25 @@ Uptime считается от момента запуска процесса No
## 12. Известные ограничения
### FFmpeg: нет libx264
На сервере (Raspberry Pi 5, Debian Bookworm) FFmpeg скомпилирован без `libx264` (требует отдельной лицензии). Видео конвертируется через `mpeg4` software encoder (`-c:v mpeg4 -q:v 5`). Это означает:
- Выходной mp4 технически корректен, но использует MPEG-4 Part 2 вместо H.264
- Совместимость чуть ниже, чем у H.264
- WebM-конвертация реализована как fallback в AVI-контейнере (`-f avi`), а не настоящий VP8/VP9
### AdminJS: медленная загрузка
ESM-импорт AdminJS при старте занимает ~6 секунд. В течение этого времени `/admin` возвращает 404 (маршрут ещё не зарегистрирован). После рестарта PM2 нужно подождать перед открытием панели.
### In-memory сессии
Сессии хранятся в памяти Node.js процесса. При `pm2 restart` все пользователи разлогиниваются. Аналогично теряются: история HTTP-клиента, задачи видеоконвертера (`jobs Map`), загруженные PDF-файлы (`pdfFiles Map`).
ESM-импорт AdminJS при старте занимает ~6 секунд. В течение этого времени `/admin` возвращает 404. После рестарта PM2 нужно подождать перед открытием панели.
### Память (swap)
На сервере swap загружен на ~100%. llama.cpp и open-webui занимают ~1.5 GB RAM. При высокой нагрузке на видеоконвертер (FFmpeg) возможна конкуренция за память. PM2 перезапустит процесс при превышении 256 MB RSS.
Swap загружен на ~100% из-за LLM-кластера (llama.cpp + open-webui ~3.8 GB). PM2 перезапустит процесс при превышении 256 MB RSS.
### CSP отключён
### PM2 cluster mode
`contentSecurityPolicy: false` в helmet. Причина: Tailwind CSS подключается через CDN и требует inline-скриптов. Это снижает защиту от XSS. Решение — собрать Tailwind локально и убрать зависимость от CDN.
Несовместим с текущей архитектурой AdminJS (ESM dynamic import + in-memory state). Работает только в fork mode (`instances: 1`).
### Производительность конвертации
Sharp работает нативно и быстро. FFmpeg и Ghostscript — CPU-интенсивные операции. На Raspberry Pi 5 конвертация длинного видео может занять несколько минут.
Sharp — нативный, быстрый. FFmpeg и Ghostscript — CPU-интенсивные. На Raspberry Pi 5 длинное видео может конвертироваться несколько минут.
### PDF-файлы
Загруженные PDF хранятся в in-memory Map с таймаутом 30 мин. При рестарте теряются (в отличие от видео-задач, которые уже в SQLite-очереди).
---
@ -1197,10 +1233,6 @@ Sharp работает нативно и быстро. FFmpeg и Ghostscript —
Добавить вход через Google и Яндекс. Пакет `openid-client` уже установлен в зависимостях, `passport` тоже присутствует — реализация не завершена.
### Persistent sessions
Перенести хранение сессий из памяти в MariaDB (пакет `express-mysql-session` или аналог). Позволит пользователям оставаться залогиненными после перезапуска сервера.
### Улучшения инструментов
| Инструмент | Улучшение |
@ -1208,16 +1240,15 @@ Sharp работает нативно и быстро. FFmpeg и Ghostscript —
| Конвертер изображений | Quality slider в UI (сейчас фиксируется в .env) |
| Генератор паролей | Режим passphrase (несколько слов через дефис) |
| HTTP-клиент | Поддержка переменных окружения (как в Postman) |
| Видео конвертер | Установить libx264 и переключить encoder |
### Usage analytics
Логирование использования инструментов в отдельную таблицу БД для аналитики популярности.
### CSP настройка
### PDF-файлы в очередь
Перейти на локальную сборку Tailwind CSS, убрать CDN, включить Content-Security-Policy.
Перенести `pdfFiles Map` в SQLite-очередь (аналогично видео-задачам) для персистентности.
### Persistent job storage
### Монетизация
Видео задачи (`jobs Map`) и PDF файлы (`pdfFiles Map`) хранить в БД или на диске с восстановлением после рестарта.
Тарифы, API-ключи, billing usage, multi-tenant лимиты.

View File

@ -1,115 +0,0 @@
# Mini Photoshop — Browser Image Editor
## Overview
Client-side image editor tool at `/editor` on images.wadevelop.ru.
All processing via Canvas API — images never leave the browser.
## File
- `/public/editor.html` — standalone page (~600 lines)
## Layout
- **Sidebar** (56px) — standard site navigation
- **Toolbar** (44px top) — Open, Export, drawing tools, transforms, undo/redo, color, zoom
- **Tool Options Bar** (36px, contextual) — appears when tool selected
- **Canvas Viewport** (center) — checkerboard bg for transparency, 3 stacked canvases
- **Status Bar** (28px bottom) — dimensions, zoom%, cursor coords, history position
## Canvas Architecture
3 overlaid `<canvas>` elements:
1. `canvasMain` — final image
2. `canvasOverlay` — preview of drawing/crop/filters (cleared each frame)
3. `canvasUI` — cursors, selection frames
Zoom/pan via CSS `transform: translate() scale()` on wrapper (GPU-accelerated).
## Undo/Redo
- History as PNG blobs (`canvas.toBlob()` → `URL.createObjectURL()`)
- Max 30 snapshots
- Ctrl+Z / Ctrl+Shift+Z
## Image Loading
- File input (Open button)
- Drag & drop on viewport
- Paste from clipboard (Ctrl+V)
- Before loading — dropzone with hint, toolbar disabled
## Tools
### Crop
- Presets: Free, 1:1, 4:3, 16:9, 3:2, 2:3, 9:16
- 8 drag handles (corners + edges)
- Semi-transparent overlay outside crop, rule of thirds
- Apply / Cancel buttons
### Resize
- Width/height in px, aspect ratio lock (on by default)
- `imageSmoothingQuality = 'high'`
- Max 16384x16384
### Rotate 90 CW/CCW
- Two buttons in toolbar
- Via temp canvas with `ctx.translate()` + `ctx.rotate()`
### Flip H/V
- Two buttons, via `ctx.scale(-1, 1)` / `ctx.scale(1, -1)`
### Brightness / Contrast / Saturation
- Three sliders -100...+100
- Preview via CSS `filter` on overlay (instant)
- Commit via pixel-level ImageData processing
- Apply / Cancel
### Brush
- Sliders: size 1-100px, opacity 0-100%
- `lineCap: 'round'`, `lineJoin: 'round'`
- Drawing on overlay → commit to main on pointerup
### Eraser
- Like brush but `globalCompositeOperation: 'destination-out'`
- Draws directly on main canvas
### Line, Rectangle, Circle, Arrow
- Stroke width 1-20px, fill toggle (rect/circle)
- Shift for snap to 45 degrees (line/arrow) and square/circle (rect/circle)
- Arrow: filled triangle arrowhead
### Text
- Font select (sans-serif, serif, monospace, Manrope, JetBrains Mono)
- Size 8-200px, Bold, Italic, Color
- Click on canvas → `<textarea>` at position → Ctrl+Enter to commit
- `ctx.fillText()` with multiline text support
### Color Picker (Eyedropper)
- Click → `getImageData(x, y, 1, 1)` → hex → sets as foreground color
- Color chosen via native `<input type="color">`
## Export
- Formats: PNG, JPEG, WebP
- Quality 1-100 (for JPEG/WebP)
- Filename (pre-filled from original)
- Warning for JPEG + transparency
- `canvas.toBlob()` → download via `<a download>`
## Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Ctrl+O | Open |
| Ctrl+S | Export |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
| B | Brush |
| E | Eraser |
| T | Text |
| C | Crop |
| L | Line |
| U | Rectangle |
| O | Ellipse |
| A | Arrow |
| I | Eyedropper |
| [ / ] | Brush size +/- |
| Space+drag | Pan |
| Ctrl+scroll | Zoom |
| Ctrl+0 | Fit |
| Ctrl+1 | 100% |
| Escape | Cancel |

View File

@ -343,7 +343,7 @@ GET /placeholder/800x600/0054e6/ffffff/Hello
### HTTP-заголовки
Helmet добавляет стандартные security headers. Политика CSP отключена (используется Tailwind CDN с inline-скриптами).
Helmet добавляет security headers + CSP (default-src self, object-src none, frame-ancestors none). Tailwind собирается локально через CLI.
### Rate Limiting (express-rate-limit)
@ -577,7 +577,7 @@ certbot --nginx -d images.wadevelop.ru
| Безопасность | Helmet | HTTP security headers |
| Rate Limiting | express-rate-limit | Ограничение частоты запросов |
| Шрифты SVG | opentype.js | Работа с шрифтами (вспомогательная) |
| UI | Tailwind CSS (CDN) | Стилизация интерфейса |
| UI | Tailwind CSS 3 (CLI build) | Стилизация интерфейса |
| Шрифты UI | JetBrains Mono, Manrope | Типографика |
| Процесс | PM2 | Управление процессом в production |
| Веб-сервер | Nginx | Reverse proxy, SSL termination |

View File

@ -1,250 +0,0 @@
# SVG Редактор — Полный функционал
**URL:** https://images.wadevelop.ru/svgeditor
**Файл:** /mnt/webdata/www/images.wadevelop.ru/public/svgeditor.html (2099 строк)
**API:** server.js — 3 эндпоинта
---
## Интерфейс (как фоторедактор /editor)
- **Тулбар** (44px) — горизонтальная панель инструментов сверху
- **Панель опций** (36px) — контекстная, показывает настройки активного инструмента
- **Вкладки** (30px) — несколько документов одновременно
- **Viewport** — тёмный фон (#2c2c30), белый SVG-холст по центру
- **Статус-бар** (28px) — размер холста, зум, координаты, кол-во элементов
- **Сайдбар** (56px) — навигация по сервисам (как во всех страницах)
---
## Инструменты рисования
| Инструмент | Клавиша | Описание |
|------------|---------|----------|
| Выделение | V | Клик для выделения, drag для перемещения |
| Прямоугольник | R | Shift — квадрат |
| Эллипс | O | Shift — круг |
| Линия | L | Shift — snap к 45° |
| Перо | P | Клик по точкам, замыкание на первую точку (красный кружок), двойной клик — открытый путь |
| Текст | T | Клик → ввод текста, двойной клик — редактирование |
### После рисования:
- Автопереключение на Выделение (V)
- Элемент сразу выделен с хэндлами
- Панель свойств открывается автоматически
---
## Выделение и трансформация
- **Клик** на элемент → выделение + 8 хэндлов (для rect/ellipse/text/group)
- **Клик** на линию → 2 хэндла на концах
- **Drag за хэндл** → ресайз (для всех типов, включая группы и AI иконки)
- **Drag за тело** → перемещение
- **Стрелки** ←↑→↓ — перемещение на 1px, Shift+стрелки — на 10px
- **Маркиз** — клик на пустое место + drag → пунктирная рамка, выделяет верхний элемент в области
- **Proximity selection** — тонкие элементы (линии, outline иконки) ловятся в радиусе 12px
- **Уже выделенный элемент** — повторный клик внутри его bounds не сбрасывает выделение
- Хэндлы прячутся при `elementFromPoint` чтобы не блокировать клик
---
## Панель опций (контекстная)
### При рисовании (rect/circle/line/path):
- Заливка — цвет + вкл/выкл (✓/✗)
- Непрозрачность — слайдер 0-100%
- Обводка — цвет + вкл/выкл
- Толщина обводки — число
### При выделении элемента:
- Те же контролы с текущими значениями элемента
- Изменения применяются сразу
### При инструменте Текст:
- Размер шрифта
- Цвет
---
## Панель свойств (правая, автопоказ)
- Позиция: X, Y (CX, CY для эллипса, X1/Y1 для линии)
- Размер: W, H (RX, RY для эллипса)
- Стиль: Fill (цвет), Stroke (цвет), Stroke-width, Opacity
- Текст: Font-size
- Появляется автоматически при выделении
- Скрывается при снятии выделения
- Кнопка × для закрытия
---
## Система слоёв
- **Слой 1** создаётся автоматически (каждый слой = `<g data-layer="true">`)
- Все новые объекты создаются на **активном слое**
- Кнопка **«+ Новый слой»** внизу панели
- **Активный слой** подсвечен синим с полоской слева
- Клик по слою → делает его активным
- Каждый слой: видимость (👁), удаление (×)
- Объекты внутри слоя: клик для выделения, × для удаления
- Группы отмечены 📁 с количеством детей
- Минимум 1 слой (нельзя удалить последний)
- Очистка холста пересоздаёт Слой 1
---
## Группировка
| Действие | Клавиша | Описание |
|----------|---------|----------|
| Группировать | Ctrl+G | Все объекты активного слоя → `<g data-name="Группа">` |
| Разгруппировать | Ctrl+Shift+G | Выделенная группа → отдельные элементы в слой |
- Слои (`data-layer`) защищены от разгруппировки
- Группа двигается/масштабируется как единый объект
- В панели слоёв группа показана как 📁
---
## Контекстное меню (правая кнопка)
- Копировать (Ctrl+C)
- Вырезать (Ctrl+X)
- Вставить (Ctrl+V)
- Дублировать (Ctrl+D)
- Удалить (Del)
- На передний план
- На задний план
- Группировать (Ctrl+G)
- Разгруппировать (Ctrl+Shift+G)
Пункты автоматически деактивируются когда неприменимы.
---
## Вкладки
- **Новый** — первая вкладка по умолчанию
- **+** — новая вкладка (Ctrl+T)
- **×** — закрыть вкладку (минимум 1)
- Каждая вкладка сохраняет: SVG-содержимое, размер холста, историю undo/redo
---
## Импорт / Экспорт
| Действие | Клавиша | Формат |
|----------|---------|--------|
| Импорт SVG | Ctrl+O | .svg файл |
| Экспорт SVG | Ctrl+S | .svg с CSS-классами (st0, st1...) |
| Экспорт PNG | — | .png 800×600 (или текущий размер холста) |
| Оптимизировать | — | Очистка через /api/svg-optimize |
| Drag & Drop | — | Перетаскивание .svg файла на холст |
### Экспорт SVG:
- Inline-атрибуты → CSS-классы в `<style>` блоке
- Одинаковые стили → один класс (`st0`, `st1`, ...)
- `data-name`, `data-layer` удаляются
- Positioning styles (left, top, width, height) удаляются
- Чистый SVG без мусора
---
## AI Генератор иконок
- Кнопка ✨ в тулбаре → модальное окно
- **45 иконок**: home, user, search, heart, star, settings, mail, phone, lock, bell, clock, cloud, download, upload, trash, plus, minus, check, close, arrow-left/right/up/down, menu, globe, link, eye, sun, moon, code, terminal, database, wifi, bookmark, share, cart, file, folder, camera и др.
- **Поиск** по названию (EN и RU)
- **Стили**: Контур / Заливка / Дуотон
- **Размеры**: 24, 32, 48, 64, 128 px
- **Цвет**: произвольный
- Иконка добавляется в центр холста на активный слой
---
## Навигация по холсту
| Действие | Способ |
|----------|--------|
| Зум | Колесо мыши / кнопки +/- |
| Зум по размеру | Ctrl+0 |
| Панорамирование | Space + drag / средняя кнопка мыши |
| Координаты | Отображаются в статус-баре в реальном времени |
---
## Горячие клавиши
| Клавиша | Действие |
|---------|----------|
| V | Выделение |
| R | Прямоугольник |
| O | Эллипс |
| L | Линия |
| P | Перо |
| T | Текст |
| Del / Backspace | Удалить выделенное |
| Ctrl+Z | Отменить |
| Ctrl+Shift+Z | Повторить |
| Ctrl+C | Копировать |
| Ctrl+V | Вставить |
| Ctrl+X | Вырезать |
| Ctrl+D | Дублировать |
| Ctrl+S | Экспорт SVG |
| Ctrl+O | Импорт SVG |
| Ctrl+N | Новый холст |
| Ctrl+T | Новая вкладка |
| Ctrl+G | Группировать |
| Ctrl+Shift+G | Разгруппировать |
| Ctrl+0 | Зум по размеру |
| ←↑→↓ | Перемещение на 1px |
| Shift+←↑→↓ | Перемещение на 10px |
| Escape | Снять выделение / отменить путь |
| Shift (при рисовании) | Квадрат / круг / snap линии к 45° |
| Space+drag | Панорамирование |
---
## Undo / Redo
- До 30 шагов истории
- Сохраняется innerHTML SVG
- История привязана к вкладке
---
## Серверные API
### GET /svgeditor
Отдаёт `public/svgeditor.html`
### POST /api/svg-optimize
Очистка SVG: удаление комментариев, XML declaration, metadata, пустых групп, data-* атрибутов, округление чисел до 2 знаков, минификация пробелов.
- Вход: `{ svg: string }`
- Выход: `{ svg: string, saved: number }` (процент экономии)
### POST /api/svg-ai
Генерация SVG-иконки по ключевому слову.
- Вход: `{ keyword, style, size, color }`
- Выход: `{ svg: string, keyword: string }`
---
## Новый холст (Ctrl+N)
Модальное окно с настройками:
- Ширина / Высота (100-4000 px)
- Пресеты: 1920×1080, 1080×1080, 800×600, 512×512, 24×24, 48×48
---
## Технические детали
- Единый HTML-файл (2099 строк), все CSS/JS inline
- SVG DOM manipulation (не Canvas)
- Шрифты: JetBrains Mono + Manrope (из /vendor/fonts.css)
- Нет внешних зависимостей кроме vendor-файлов
- IIFE-обёртка для изоляции переменных
- `requestAnimationFrame` для корректного позиционирования хэндлов
- `getScreenCTM()` / `getBoundingClientRect()` для визуальных координат с учётом transform

3
input.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,31 +1,27 @@
const fs = require('fs');
const path = require('path');
const pino = require('pino');
const LOG_FILE = path.join(__dirname, '..', 'compress.log');
const LEVEL_PRIORITY = { error: 0, warn: 1, info: 2, debug: 3 };
const currentLevel = LEVEL_PRIORITY[process.env.LOG_LEVEL || 'info'] ?? 2;
function formatTimestamp() {
return new Date().toISOString();
}
function log(level, msg, meta) {
if ((LEVEL_PRIORITY[level] ?? 2) > currentLevel) return;
const ts = formatTimestamp();
const metaStr = meta ? ' ' + JSON.stringify(meta) : '';
const line = `[${ts}] [${level.toUpperCase()}] ${msg}${metaStr}`;
process.stdout.write(line + '\n');
}
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV !== 'production' ? {
target: 'pino/file',
options: { destination: 1 },
} : undefined,
});
function logToFile(text) {
const ts = formatTimestamp();
const ts = new Date().toISOString();
fs.appendFile(LOG_FILE, `[${ts}] ${text}\n`, () => {});
}
module.exports = {
info: (msg, meta) => log('info', msg, meta),
warn: (msg, meta) => log('warn', msg, meta),
error: (msg, meta) => log('error', msg, meta),
debug: (msg, meta) => log('debug', msg, meta),
info: (msg, meta) => meta ? logger.info(meta, msg) : logger.info(msg),
warn: (msg, meta) => meta ? logger.warn(meta, msg) : logger.warn(msg),
error: (msg, meta) => meta ? logger.error(meta, msg) : logger.error(msg),
debug: (msg, meta) => meta ? logger.debug(meta, msg) : logger.debug(msg),
logToFile,
logger,
};

136
lib/queue.js Normal file
View File

@ -0,0 +1,136 @@
const Database = require('better-sqlite3');
const path = require('path');
const log = require('./logger');
const DB_PATH = path.join('/mnt/webdata/storage', 'jobs.db');
const db = new Database(DB_PATH);
// WAL mode for better concurrency
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// Create jobs table
db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT NOT NULL DEFAULT '{}',
result TEXT,
progress INTEGER DEFAULT 0,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type)`);
// Prepared statements
const stmts = {
insert: db.prepare(`INSERT INTO jobs (id, type, status, payload, progress, created_at, updated_at) VALUES (?, ?, 'pending', ?, 0, ?, ?)`),
get: db.prepare(`SELECT * FROM jobs WHERE id = ?`),
updateStatus: db.prepare(`UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?`),
updateProgress: db.prepare(`UPDATE jobs SET progress = ?, updated_at = ? WHERE id = ?`),
finish: db.prepare(`UPDATE jobs SET status = 'done', progress = 100, result = ?, finished_at = ?, updated_at = ? WHERE id = ?`),
fail: db.prepare(`UPDATE jobs SET status = 'error', error = ?, finished_at = ?, updated_at = ? WHERE id = ?`),
pending: db.prepare(`SELECT * FROM jobs WHERE type = ? AND status = 'pending' ORDER BY created_at LIMIT ?`),
cleanup: db.prepare(`DELETE FROM jobs WHERE finished_at < ? AND status IN ('done', 'error')`),
stats: db.prepare(`SELECT status, COUNT(*) as count FROM jobs GROUP BY status`),
activeCount: db.prepare(`SELECT COUNT(*) as count FROM jobs WHERE status = 'processing'`),
queueDepth: db.prepare(`SELECT COUNT(*) as count FROM jobs WHERE status = 'pending'`),
};
function generateId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function addJob(type, payload) {
const id = generateId(type);
const now = Date.now();
stmts.insert.run(id, type, JSON.stringify(payload), now, now);
log.info(`Job added: ${id}`, { type });
return id;
}
function getJob(id) {
const row = stmts.get.get(id);
if (!row) return null;
row.payload = JSON.parse(row.payload);
if (row.result) row.result = JSON.parse(row.result);
return row;
}
function startJob(id) {
const now = Date.now();
db.prepare(`UPDATE jobs SET status = 'processing', started_at = ?, updated_at = ? WHERE id = ?`).run(now, now, id);
}
function updateProgress(id, progress) {
stmts.updateProgress.run(progress, Date.now(), id);
}
function finishJob(id, result) {
const now = Date.now();
stmts.finish.run(JSON.stringify(result), now, now, id);
log.info(`Job done: ${id}`);
}
function failJob(id, error) {
const now = Date.now();
stmts.fail.run(String(error).slice(0, 500), now, now, id);
log.error(`Job failed: ${id}`, { error: String(error).slice(0, 200) });
}
function getPending(type, limit = 5) {
return stmts.pending.all(type, limit).map(row => {
row.payload = JSON.parse(row.payload);
return row;
});
}
function getStats() {
const rows = stmts.stats.all();
const stats = {};
for (const r of rows) stats[r.status] = r.count;
return stats;
}
function getActiveCount() {
return stmts.activeCount.get().count;
}
function getQueueDepth() {
return stmts.queueDepth.get().count;
}
// Cleanup old finished jobs (older than 1 hour)
function cleanup() {
const cutoff = Date.now() - 3600000;
const result = stmts.cleanup.run(cutoff);
if (result.changes > 0) log.info(`Cleaned up ${result.changes} old jobs`);
}
// Run cleanup every 10 min
setInterval(cleanup, 600000);
// Graceful close
process.on('exit', () => { try { db.close(); } catch {} });
module.exports = {
addJob,
getJob,
startJob,
updateProgress,
finishJob,
failJob,
getPending,
getStats,
getActiveCount,
getQueueDepth,
cleanup,
db,
};

29
lib/session.js Normal file
View File

@ -0,0 +1,29 @@
const session = require('express-session');
const MySQLStore = require('express-mysql-session')(session);
const store = new MySQLStore({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'wa_tools',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'wa_tools',
clearExpired: true,
checkExpirationInterval: 900000,
expiration: 86400000,
createDatabaseTable: true,
});
module.exports = session({
key: 'wa_dev_tools',
secret: process.env.SESSION_SECRET || 'change-me-in-env',
store,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 86400000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
},
});
module.exports.store = store;

13
lib/storage.js Normal file
View File

@ -0,0 +1,13 @@
const fs = require('fs');
const path = require('path');
const STORAGE_ROOT = '/mnt/webdata/storage';
const UPLOADS_DIR = path.join(STORAGE_ROOT, 'uploads');
const RESULTS_DIR = path.join(STORAGE_ROOT, 'results');
// Ensure dirs exist
for (const dir of [UPLOADS_DIR, RESULTS_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
module.exports = { STORAGE_ROOT, UPLOADS_DIR, RESULTS_DIR };

57
lib/ws.js Normal file
View File

@ -0,0 +1,57 @@
const { WebSocketServer } = require('ws');
const log = require('./logger');
let wss = null;
// Map jobId → Set of connected clients
const subscribers = new Map();
function attach(server) {
wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws._jobId = null;
ws.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'subscribe' && msg.jobId) {
// Unsubscribe from previous
if (ws._jobId) {
const prev = subscribers.get(ws._jobId);
if (prev) { prev.delete(ws); if (prev.size === 0) subscribers.delete(ws._jobId); }
}
// Subscribe to new job
ws._jobId = msg.jobId;
if (!subscribers.has(msg.jobId)) subscribers.set(msg.jobId, new Set());
subscribers.get(msg.jobId).add(ws);
}
} catch {}
});
ws.on('close', () => {
if (ws._jobId) {
const subs = subscribers.get(ws._jobId);
if (subs) { subs.delete(ws); if (subs.size === 0) subscribers.delete(ws._jobId); }
}
});
});
log.info('WebSocket server attached at /ws');
}
function notify(jobId, data) {
const subs = subscribers.get(jobId);
if (!subs || subs.size === 0) return;
const msg = JSON.stringify({ jobId, ...data });
for (const ws of subs) {
if (ws.readyState === 1) { // OPEN
ws.send(msg);
}
}
}
function getConnectionCount() {
return wss ? wss.clients.size : 0;
}
module.exports = { attach, notify, getConnectionCount };

1396
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,9 @@
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
"start": "node server.js",
"tw:build": "tailwindcss -i ./input.css -o ./public/vendor/tailwind.min.css --minify",
"tw:watch": "tailwindcss -i ./input.css -o ./public/vendor/tailwind.min.css --watch"
},
"keywords": [],
"author": "",
@ -19,9 +21,11 @@
"adminjs": "^7.8.17",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"express": "^5.1.0",
"express-formidable": "^1.2.0",
"express-mysql-session": "^3.0.3",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
@ -37,7 +41,12 @@
"pdf-lib": "^1.17.1",
"pdf-merger-js": "^5.1.2",
"pdf-parse": "^2.4.5",
"pino": "^10.3.1",
"sequelize": "^6.37.8",
"sharp": "^0.34.1"
"sharp": "^0.34.1",
"ws": "^8.20.0"
},
"devDependencies": {
"tailwindcss": "^3.4.19"
}
}

View File

@ -1,250 +0,0 @@
# SVG Редактор — Полный функционал
**URL:** https://images.wadevelop.ru/svgeditor
**Файл:** /mnt/webdata/www/images.wadevelop.ru/public/svgeditor.html (2099 строк)
**API:** server.js — 3 эндпоинта
---
## Интерфейс (как фоторедактор /editor)
- **Тулбар** (44px) — горизонтальная панель инструментов сверху
- **Панель опций** (36px) — контекстная, показывает настройки активного инструмента
- **Вкладки** (30px) — несколько документов одновременно
- **Viewport** — тёмный фон (#2c2c30), белый SVG-холст по центру
- **Статус-бар** (28px) — размер холста, зум, координаты, кол-во элементов
- **Сайдбар** (56px) — навигация по сервисам (как во всех страницах)
---
## Инструменты рисования
| Инструмент | Клавиша | Описание |
|------------|---------|----------|
| Выделение | V | Клик для выделения, drag для перемещения |
| Прямоугольник | R | Shift — квадрат |
| Эллипс | O | Shift — круг |
| Линия | L | Shift — snap к 45° |
| Перо | P | Клик по точкам, замыкание на первую точку (красный кружок), двойной клик — открытый путь |
| Текст | T | Клик → ввод текста, двойной клик — редактирование |
### После рисования:
- Автопереключение на Выделение (V)
- Элемент сразу выделен с хэндлами
- Панель свойств открывается автоматически
---
## Выделение и трансформация
- **Клик** на элемент → выделение + 8 хэндлов (для rect/ellipse/text/group)
- **Клик** на линию → 2 хэндла на концах
- **Drag за хэндл** → ресайз (для всех типов, включая группы и AI иконки)
- **Drag за тело** → перемещение
- **Стрелки** ←↑→↓ — перемещение на 1px, Shift+стрелки — на 10px
- **Маркиз** — клик на пустое место + drag → пунктирная рамка, выделяет верхний элемент в области
- **Proximity selection** — тонкие элементы (линии, outline иконки) ловятся в радиусе 12px
- **Уже выделенный элемент** — повторный клик внутри его bounds не сбрасывает выделение
- Хэндлы прячутся при `elementFromPoint` чтобы не блокировать клик
---
## Панель опций (контекстная)
### При рисовании (rect/circle/line/path):
- Заливка — цвет + вкл/выкл (✓/✗)
- Непрозрачность — слайдер 0-100%
- Обводка — цвет + вкл/выкл
- Толщина обводки — число
### При выделении элемента:
- Те же контролы с текущими значениями элемента
- Изменения применяются сразу
### При инструменте Текст:
- Размер шрифта
- Цвет
---
## Панель свойств (правая, автопоказ)
- Позиция: X, Y (CX, CY для эллипса, X1/Y1 для линии)
- Размер: W, H (RX, RY для эллипса)
- Стиль: Fill (цвет), Stroke (цвет), Stroke-width, Opacity
- Текст: Font-size
- Появляется автоматически при выделении
- Скрывается при снятии выделения
- Кнопка × для закрытия
---
## Система слоёв
- **Слой 1** создаётся автоматически (каждый слой = `<g data-layer="true">`)
- Все новые объекты создаются на **активном слое**
- Кнопка **«+ Новый слой»** внизу панели
- **Активный слой** подсвечен синим с полоской слева
- Клик по слою → делает его активным
- Каждый слой: видимость (👁), удаление (×)
- Объекты внутри слоя: клик для выделения, × для удаления
- Группы отмечены 📁 с количеством детей
- Минимум 1 слой (нельзя удалить последний)
- Очистка холста пересоздаёт Слой 1
---
## Группировка
| Действие | Клавиша | Описание |
|----------|---------|----------|
| Группировать | Ctrl+G | Все объекты активного слоя → `<g data-name="Группа">` |
| Разгруппировать | Ctrl+Shift+G | Выделенная группа → отдельные элементы в слой |
- Слои (`data-layer`) защищены от разгруппировки
- Группа двигается/масштабируется как единый объект
- В панели слоёв группа показана как 📁
---
## Контекстное меню (правая кнопка)
- Копировать (Ctrl+C)
- Вырезать (Ctrl+X)
- Вставить (Ctrl+V)
- Дублировать (Ctrl+D)
- Удалить (Del)
- На передний план
- На задний план
- Группировать (Ctrl+G)
- Разгруппировать (Ctrl+Shift+G)
Пункты автоматически деактивируются когда неприменимы.
---
## Вкладки
- **Новый** — первая вкладка по умолчанию
- **+** — новая вкладка (Ctrl+T)
- **×** — закрыть вкладку (минимум 1)
- Каждая вкладка сохраняет: SVG-содержимое, размер холста, историю undo/redo
---
## Импорт / Экспорт
| Действие | Клавиша | Формат |
|----------|---------|--------|
| Импорт SVG | Ctrl+O | .svg файл |
| Экспорт SVG | Ctrl+S | .svg с CSS-классами (st0, st1...) |
| Экспорт PNG | — | .png 800×600 (или текущий размер холста) |
| Оптимизировать | — | Очистка через /api/svg-optimize |
| Drag & Drop | — | Перетаскивание .svg файла на холст |
### Экспорт SVG:
- Inline-атрибуты → CSS-классы в `<style>` блоке
- Одинаковые стили → один класс (`st0`, `st1`, ...)
- `data-name`, `data-layer` удаляются
- Positioning styles (left, top, width, height) удаляются
- Чистый SVG без мусора
---
## AI Генератор иконок
- Кнопка ✨ в тулбаре → модальное окно
- **45 иконок**: home, user, search, heart, star, settings, mail, phone, lock, bell, clock, cloud, download, upload, trash, plus, minus, check, close, arrow-left/right/up/down, menu, globe, link, eye, sun, moon, code, terminal, database, wifi, bookmark, share, cart, file, folder, camera и др.
- **Поиск** по названию (EN и RU)
- **Стили**: Контур / Заливка / Дуотон
- **Размеры**: 24, 32, 48, 64, 128 px
- **Цвет**: произвольный
- Иконка добавляется в центр холста на активный слой
---
## Навигация по холсту
| Действие | Способ |
|----------|--------|
| Зум | Колесо мыши / кнопки +/- |
| Зум по размеру | Ctrl+0 |
| Панорамирование | Space + drag / средняя кнопка мыши |
| Координаты | Отображаются в статус-баре в реальном времени |
---
## Горячие клавиши
| Клавиша | Действие |
|---------|----------|
| V | Выделение |
| R | Прямоугольник |
| O | Эллипс |
| L | Линия |
| P | Перо |
| T | Текст |
| Del / Backspace | Удалить выделенное |
| Ctrl+Z | Отменить |
| Ctrl+Shift+Z | Повторить |
| Ctrl+C | Копировать |
| Ctrl+V | Вставить |
| Ctrl+X | Вырезать |
| Ctrl+D | Дублировать |
| Ctrl+S | Экспорт SVG |
| Ctrl+O | Импорт SVG |
| Ctrl+N | Новый холст |
| Ctrl+T | Новая вкладка |
| Ctrl+G | Группировать |
| Ctrl+Shift+G | Разгруппировать |
| Ctrl+0 | Зум по размеру |
| ←↑→↓ | Перемещение на 1px |
| Shift+←↑→↓ | Перемещение на 10px |
| Escape | Снять выделение / отменить путь |
| Shift (при рисовании) | Квадрат / круг / snap линии к 45° |
| Space+drag | Панорамирование |
---
## Undo / Redo
- До 30 шагов истории
- Сохраняется innerHTML SVG
- История привязана к вкладке
---
## Серверные API
### GET /svgeditor
Отдаёт `public/svgeditor.html`
### POST /api/svg-optimize
Очистка SVG: удаление комментариев, XML declaration, metadata, пустых групп, data-* атрибутов, округление чисел до 2 знаков, минификация пробелов.
- Вход: `{ svg: string }`
- Выход: `{ svg: string, saved: number }` (процент экономии)
### POST /api/svg-ai
Генерация SVG-иконки по ключевому слову.
- Вход: `{ keyword, style, size, color }`
- Выход: `{ svg: string, keyword: string }`
---
## Новый холст (Ctrl+N)
Модальное окно с настройками:
- Ширина / Высота (100-4000 px)
- Пресеты: 1920×1080, 1080×1080, 800×600, 512×512, 24×24, 48×48
---
## Технические детали
- Единый HTML-файл (2099 строк), все CSS/JS inline
- SVG DOM manipulation (не Canvas)
- Шрифты: JetBrains Mono + Manrope (из /vendor/fonts.css)
- Нет внешних зависимостей кроме vendor-файлов
- IIFE-обёртка для изоляции переменных
- `requestAnimationFrame` для корректного позиционирования хэндлов
- `getScreenCTM()` / `getBoundingClientRect()` для визуальных координат с учётом transform

View File

@ -4,28 +4,105 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Конвертер изображений — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'compress';</script>
<style>
.dropzone-active { border-color: #0054e6 !important; background: rgba(0, 84, 230, 0.05) !important; }
.dropzone-active .drop-icon { transform: scale(1.1) translateY(-4px); }
.drop-icon { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
.savings-bar { height: 6px; border-radius: 3px; background: var(--bar-bg); overflow: hidden; }
.savings-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
@keyframes pulse-border { 0%, 100% { border-color: rgba(0, 84, 230, 0.2); } 50% { border-color: rgba(0, 84, 230, 0.5); } }
.processing { animation: pulse-border 1.5s ease-in-out infinite; }
.preview-grid img { transition: transform 0.2s ease; }
.preview-grid img:hover { transform: scale(1.05); }
progress { appearance: none; height: 4px; border-radius: 2px; overflow: hidden; }
progress::-webkit-progress-bar { background: var(--bar-bg); border-radius: 2px; }
progress::-webkit-progress-value { background: linear-gradient(90deg, #0043b8, #0054e6); border-radius: 2px; transition: width 0.2s; }
select, select option { background: var(--select-bg); color: var(--select-color); }
/* ── Dropzone ── */
.dropzone-wrap { position: relative; border: 2px dashed var(--surface-600); border-radius: 14px; padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .25s, background .25s; }
.dropzone-wrap:hover, .dropzone-wrap.active { border-color: var(--accent); background: var(--accent-bg); }
.dropzone-wrap .drop-icon { transition: transform .3s cubic-bezier(.34,1.56,.64,1); }
.dropzone-wrap.active .drop-icon { transform: scale(1.12) translateY(-4px); }
/* ── Settings bar ── */
.settings-row { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
.settings-row > * { flex: 1 1 120px; min-width: 0; }
.ctrl-label { font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-bottom: 5px; }
.ctrl-select { width: 100%; padding: 8px 10px; background: var(--select-bg); color: var(--select-color); border: 1px solid var(--surface-600); border-radius: 8px; font-size: 13px; outline: none; transition: border-color .2s; }
.ctrl-select:focus { border-color: var(--accent); }
.ctrl-select option { background: var(--select-bg); color: var(--select-color); }
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
/* ── Summary bar ── */
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
.summary-bar.hidden { display: none; }
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
.summary-stat strong { color: var(--text-primary); }
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
/* ── File list ── */
.file-list { margin-top: 12px; border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
.file-list.hidden { display: none; }
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--surface-600); transition: background .15s; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: var(--accent-bg); }
/* Thumbnail */
.file-thumb { width: 44px; height: 44px; border-radius: 7px; object-fit: cover; border: 1px solid var(--surface-600); flex-shrink: 0; background: var(--surface-700); }
.file-thumb-placeholder { width: 44px; height: 44px; border-radius: 7px; border: 1px solid var(--surface-600); flex-shrink: 0; background: var(--surface-700); display: flex; align-items: center; justify-content: center; }
/* Name + format */
.file-info { flex: 1 1 0; min-width: 0; }
.file-name { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.fmt-badge { font-size: 10px; font-weight: 700; letter-spacing: .05em; padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; }
.fmt-jpeg { background: rgba(0,188,212,.15); color: #00bcd4; }
.fmt-png { background: rgba(76,175,80,.15); color: #4caf50; }
.fmt-webp { background: rgba(156,39,176,.15); color: #9c27b0; }
.fmt-avif { background: rgba(255,152,0,.15); color: #ff9800; }
.fmt-tiff { background: rgba(33,150,243,.15); color: #2196f3; }
.fmt-gif { background: rgba(233,30,99,.15); color: #e91e63; }
.fmt-other{ background: rgba(128,128,128,.15);color: #888; }
.file-orig-size { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
/* Right side: result */
.file-result { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.savings-pct { font-size: 15px; font-weight: 800; font-family: 'JetBrains Mono', monospace; min-width: 46px; text-align: right; }
.savings-positive { color: #4caf50; }
.savings-mid { color: var(--accent); }
.savings-low { color: #ff9800; }
.savings-negative { color: #f44336; }
.new-size { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; text-align: right; margin-top: 1px; }
/* Download btn per file */
.dl-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .04em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer; text-decoration: none; transition: background .2s, color .2s; white-space: nowrap; }
.dl-btn:hover { background: var(--accent); color: #fff; }
.dl-btn svg { flex-shrink: 0; }
/* Loading spinner row */
.file-row.loading .savings-pct { display: none; }
.file-row.loading .dl-btn { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width: 18px; height: 18px; border: 2px solid var(--surface-600); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0; }
/* Download all */
.dl-all-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 13px; background: var(--accent); color: #fff; border: none; border-radius: 12px; font-size: 14px; font-weight: 700; cursor: pointer; transition: background .2s, box-shadow .2s; margin-top: 14px; text-decoration: none; }
.dl-all-btn:hover { background: var(--accent-dim); box-shadow: 0 6px 20px rgba(0,84,230,.25); }
.dl-all-btn.hidden { display: none; }
/* Animations */
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp .35s ease-out forwards; }
.fade-up-delay { animation: fadeUp .35s ease-out .1s forwards; opacity: 0; }
.file-row { animation: fadeUp .3s ease-out forwards; }
/* Error */
.error-block { background: rgba(244,67,54,.08); border: 1px solid rgba(244,67,54,.2); border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #f44336; margin-top: 14px; }
.error-block.hidden { display: none; }
/* Responsive */
@media (max-width: 600px) {
.settings-row { gap: 8px; }
.settings-row > * { flex: 1 1 100px; }
.file-row { gap: 8px; padding: 8px 10px; }
.summary-bar { flex-direction: column; align-items: flex-start; gap: 6px; }
}
@media (max-width: 420px) {
.file-result { flex-direction: column; align-items: flex-end; gap: 4px; }
}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
@ -43,7 +120,7 @@
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Конвертер <span class="text-accent">изображений</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Сжатие, ресайз и конвертация до 50 файлов · до 20 МБ</p>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Сжатие, ресайз и конвертация · до 50 файлов · до 20 МБ</p>
</div>
</div>
@ -51,48 +128,36 @@
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<!-- Dropzone -->
<div id="dropzone" class="relative border-2 border-dashed dark:border-surface-600 border-gray-300 rounded-xl p-8 sm:p-10 text-center cursor-pointer dark:hover:border-gray-500 hover:border-gray-400 transition-all duration-300 group">
<div id="dropzone" class="dropzone-wrap" role="button" tabindex="0" aria-label="Зона загрузки файлов">
<div class="drop-icon mb-3">
<svg class="mx-auto w-10 h-10 dark:text-gray-500 text-gray-400 group-hover:text-accent transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<svg class="mx-auto w-10 h-10" style="color:var(--text-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p class="text-sm dark:text-gray-400 text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors">
Перетащите файлы сюда или <span class="text-accent font-medium">выберите</span>
<p style="font-size:14px;color:var(--text-muted)">
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
</p>
<input type="file" multiple accept="image/jpeg,image/png,image/webp" id="fileInput" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p>
<input type="file" multiple accept="image/*" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
</div>
<!-- Preview -->
<div id="preview" class="preview-grid grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4 hidden"></div>
<!-- File count badge -->
<div id="fileCount" class="hidden mt-3 text-center">
<span class="inline-flex items-center gap-1.5 text-xs font-mono dark:text-gray-400 text-gray-500 dark:bg-surface-700 bg-gray-100 px-3 py-1 rounded-full">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span id="fileCountText">0 файлов</span>
</span>
</div>
<!-- Controls -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-5">
<!-- Settings row -->
<div class="settings-row">
<div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label>
<select id="format" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
<div class="ctrl-label">Формат</div>
<select id="format" class="ctrl-select">
<option value="original">Оригинал</option>
<option value="avif">AVIF</option>
<option value="tiff">TIFF</option>
<option value="gif">GIF</option>
<option value="webp">WebP</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="avif">AVIF</option>
<option value="tiff">TIFF</option>
<option value="gif">GIF</option>
</select>
</div>
<div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Размер</label>
<select id="resize" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
<div class="ctrl-label">Размер</div>
<select id="resize" class="ctrl-select">
<option value="0">Не изменять</option>
<option value="4600">4600px</option>
<option value="3600">3600px</option>
@ -103,269 +168,382 @@
<option value="custom">Свой размер...</option>
</select>
</div>
<div> <label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Качество</label> <input type="range" id="quality" min="10" max="100" value="80" class="w-full" oninput="document.getElementById('qualityVal').textContent=this.value+'%'" /> <div class="text-xs text-center dark:text-gray-500 text-gray-400 mt-1" id="qualityVal">80%</div> </div>
</div>
<!-- Progress -->
<progress id="progressBar" class="w-full mt-4 hidden" value="0" max="100"></progress>
<!-- Compress button -->
<button id="compressBtn" disabled
class="mt-5 w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent/10 text-accent/40 border border-accent/10 cursor-not-allowed
enabled:bg-accent enabled:text-white enabled:border-accent enabled:cursor-pointer
enabled:hover:bg-accent-bright enabled:hover:shadow-lg enabled:hover:shadow-accent/20
enabled:active:scale-[0.98]">
Сжать
</button>
</div>
<!-- Results -->
<div id="results" class="hidden mt-5 fade-up">
<!-- Stats card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl overflow-hidden shadow-sm dark:shadow-none">
<!-- Summary header -->
<div id="summary" class="flex items-center justify-between px-5 sm:px-7 py-4 border-b dark:border-surface-600 border-gray-200 dark:bg-surface-700/30 bg-gray-50/50">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div>
<div class="text-sm font-semibold dark:text-white text-gray-900" id="summaryTitle">Готово</div>
<div class="text-xs dark:text-gray-500 text-gray-400 font-mono" id="summaryDetail"></div>
<div class="ctrl-label">Качество</div>
<input type="range" id="quality" min="10" max="100" value="80" style="width:100%"
oninput="document.getElementById('qualityVal').textContent=this.value+'%'">
<div class="quality-val" id="qualityVal">80%</div>
</div>
</div>
<div class="text-right">
<div class="text-lg font-bold font-mono" id="summaryPercent"></div>
<div class="text-xs dark:text-gray-500 text-gray-400">экономия</div>
</div>
</div>
<!-- Stats table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider border-b dark:border-surface-600 border-gray-200">
<th class="text-left px-5 py-3">Файл</th>
<th class="text-right px-3 py-3">Было</th>
<th class="text-right px-3 py-3">Стало</th>
<th class="text-right px-5 py-3 w-32">Экономия</th>
</tr>
</thead>
<tbody id="statsBody"></tbody>
</table>
<!-- Error block -->
<div id="errorBlock" class="error-block hidden"></div>
<!-- Summary bar -->
<div id="summaryBar" class="summary-bar hidden">
<div>
<div style="font-size:13px;font-weight:700;color:var(--text-primary)" id="summaryTitle">0 файлов</div>
<div class="summary-stat" id="summaryDetail"></div>
</div>
<div style="text-align:right">
<div class="summary-savings" id="summarySavings" style="color:var(--accent)"></div>
<div style="font-size:11px;color:var(--text-muted)">общая экономия</div>
</div>
</div>
<!-- Download button -->
<a id="downloadBtn" href="#" download
class="mt-4 flex items-center justify-center gap-2 w-full py-3.5 rounded-xl font-semibold text-sm tracking-wide
bg-accent text-white hover:bg-accent-bright transition-all duration-300
hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<!-- File list -->
<div id="fileList" class="file-list hidden"></div>
<!-- Download all -->
<button id="dlAllBtn" class="dl-all-btn hidden" type="button">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Скачать архив
</a>
</div>
Скачать всё архивом
</button>
<!-- Error -->
<div id="errorBlock" class="hidden mt-5 bg-danger/10 border border-danger/20 rounded-xl px-5 py-4 text-sm text-danger font-medium fade-up"></div>
</div>
</div><!-- /main-content -->
<script>
const $ = id => document.getElementById(id);
const dropzone = $('dropzone');
const fileInput = $('fileInput');
const progressBar = $('progressBar');
const compressBtn = $('compressBtn');
const results = $('results');
const errorBlock = $('errorBlock');
const preview = $('preview');
let selectedFiles = null;
function formatSize(bytes) {
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
}
function pluralFiles(n) {
const mod = n % 10;
const mod100 = n % 100;
if (mod === 1 && mod100 !== 11) return n + ' файл';
if (mod >= 2 && mod <= 4 && (mod100 < 12 || mod100 > 14)) return n + ' файла';
return n + ' файлов';
function fmtBadge(mimetype, outputName) {
const ext = (outputName || '').split('.').pop().toLowerCase();
const typeMap = {
'image/jpeg': 'jpeg', 'image/jpg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
'image/tiff': 'tiff',
'image/gif': 'gif',
};
let fmt = typeMap[mimetype] || ext || 'file';
const label = fmt.toUpperCase();
const cls = ['jpeg','png','webp','avif','tiff','gif'].includes(fmt) ? 'fmt-' + fmt : 'fmt-other';
return '<span class="fmt-badge ' + cls + '">' + label + '</span>';
}
// Drag & drop
function savingsClass(pct) {
if (pct >= 30) return 'savings-positive';
if (pct >= 10) return 'savings-mid';
if (pct >= 0) return 'savings-low';
return 'savings-negative';
}
function dlIcon() {
return '<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>';
}
function showError(msg) {
const el = document.getElementById('errorBlock');
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 6000);
}
// ── State ─────────────────────────────────────────────────────────────────────
// Each entry: { id, file, rowEl, status: 'pending'|'uploading'|'done'|'error', result }
const fileEntries = [];
let entryCounter = 0;
let pendingQueue = [];
let activeUploads = 0;
const MAX_CONCURRENT = 3;
// Batch download state (completed results)
let batchFiles = []; // {originalFile, result}
// ── DOM refs ─────────────────────────────────────────────────────────────────
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const summaryBar = document.getElementById('summaryBar');
const dlAllBtn = document.getElementById('dlAllBtn');
// ── Dropzone events ───────────────────────────────────────────────────────────
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dropzone-active'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dropzone-active'));
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dropzone-active'); processFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', e => processFiles(e.target.files));
dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('active'); });
dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('active'); });
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('active'); handleFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', e => { handleFiles(e.target.files); e.target.value = ''; });
function processFiles(files) {
results.classList.add('hidden');
errorBlock.classList.add('hidden');
preview.innerHTML = '';
preview.classList.add('hidden');
compressBtn.disabled = true;
// ── File handling ─────────────────────────────────────────────────────────────
// Handle custom resize: prompt immediately on select change
document.getElementById('resize').addEventListener('change', function() {
if (this.value === 'custom') {
const val = prompt('Размер по длинной стороне (px):', '1024');
if (val && parseInt(val) > 0) {
// Add a custom option with the value
let customOpt = this.querySelector('option[data-custom]');
if (!customOpt) {
customOpt = document.createElement('option');
customOpt.setAttribute('data-custom', '1');
this.appendChild(customOpt);
}
customOpt.value = val;
customOpt.textContent = val + 'px';
this.value = val;
} else {
this.value = '0'; // reset to "Не изменять"
}
}
});
if (!files.length) return;
const VALID_TYPES = ['image/jpeg','image/png','image/webp','image/avif','image/tiff','image/gif','image/bmp','image/svg+xml'];
const MAX_SIZE = 20 * 1024 * 1024;
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024);
function handleFiles(files) {
const arr = Array.from(files);
const valid = arr.filter(f => (VALID_TYPES.includes(f.type) || /\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i.test(f.name)) && f.size <= MAX_SIZE);
const invalid = arr.length - valid.length;
if (!valid.length) {
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
return;
}
if (invalid > 0) {
showError(invalid + ' файл(ов) пропущено (неверный формат или размер > 20 МБ)');
}
selectedFiles = valid;
$('fileCount').classList.remove('hidden');
$('fileCountText').textContent = pluralFiles(valid.length);
valid.forEach(f => addFileEntry(f));
drainQueue();
}
// Show previews (max 8)
const shown = valid.slice(0, 8);
shown.forEach(file => {
function addFileEntry(file) {
const id = ++entryCounter;
const rowEl = createRow(id, file);
fileList.classList.remove('hidden');
fileList.appendChild(rowEl);
summaryBar.classList.remove('hidden');
dlAllBtn.classList.add('hidden'); // hide until all done
const entry = { id, file, rowEl, status: 'pending', result: null };
fileEntries.push(entry);
pendingQueue.push(entry);
}
function createRow(id, file) {
const row = document.createElement('div');
row.className = 'file-row loading';
row.id = 'row-' + id;
// Thumbnail placeholder
const thumbWrap = document.createElement('div');
thumbWrap.className = 'file-thumb-placeholder';
thumbWrap.id = 'thumb-' + id;
thumbWrap.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--text-muted)"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>';
row.appendChild(thumbWrap);
// Load thumbnail
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'w-full aspect-square object-cover rounded-lg border dark:border-surface-600 border-gray-200';
preview.appendChild(img);
preview.classList.remove('hidden');
img.className = 'file-thumb';
img.alt = '';
const ph = document.getElementById('thumb-' + id);
if (ph) ph.replaceWith(img);
};
reader.readAsDataURL(file);
});
if (valid.length > 8) {
const more = document.createElement('div');
more.className = 'w-full aspect-square rounded-lg border dark:border-surface-600 border-gray-200 dark:bg-surface-700 bg-gray-100 flex items-center justify-center text-xs dark:text-gray-500 text-gray-400 font-mono';
more.textContent = '+' + (valid.length - 8);
preview.appendChild(more);
preview.classList.remove('hidden');
// Info
const info = document.createElement('div');
info.className = 'file-info';
info.innerHTML = '<div class="file-name" title="' + escHtml(file.name) + '">' + escHtml(truncName(file.name, 38)) + '</div>'
+ '<div class="file-meta">'
+ fmtBadge(file.type, file.name)
+ '<span class="file-orig-size">' + fmtSize(file.size) + '</span>'
+ '</div>';
row.appendChild(info);
// Result area
const result = document.createElement('div');
result.className = 'file-result';
result.id = 'result-' + id;
result.innerHTML = '<div class="spinner"></div>';
row.appendChild(result);
return row;
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function truncName(name, maxLen) {
if (name.length <= maxLen) return name;
const ext = name.lastIndexOf('.');
if (ext > 0) {
const e = name.slice(ext);
return name.slice(0, maxLen - e.length - 3) + '...' + e;
}
compressBtn.disabled = false;
compressBtn.textContent = 'Сжать';
return name.slice(0, maxLen - 3) + '...';
}
function showError(msg) {
errorBlock.textContent = msg;
errorBlock.classList.remove('hidden');
// ── Upload queue ──────────────────────────────────────────────────────────────
function drainQueue() {
while (activeUploads < MAX_CONCURRENT && pendingQueue.length > 0) {
const entry = pendingQueue.shift();
uploadEntry(entry);
}
}
compressBtn.addEventListener('click', () => {
if (!selectedFiles || !selectedFiles.length) return;
compressBtn.disabled = true;
compressBtn.textContent = 'Сжимаю...';
results.classList.add('hidden');
errorBlock.classList.add('hidden');
progressBar.hidden = false;
progressBar.value = 0;
function uploadEntry(entry) {
activeUploads++;
entry.status = 'uploading';
const formData = new FormData();
selectedFiles.forEach(f => formData.append('images', f));
formData.append('format', $('format').value);
formData.append('quality', $('quality').value);
var rv = $('resize').value;
if (rv === 'custom') rv = prompt('Размер по длинной стороне (px):', '1024') || '0';
formData.append('image', entry.file);
formData.append('format', document.getElementById('format').value);
formData.append('quality', document.getElementById('quality').value);
let rv = document.getElementById('resize').value;
if (rv === 'custom') rv = '0';
formData.append('resize', rv);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/compress');
xhr.responseType = 'json';
xhr.upload.onprogress = e => {
if (e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100;
};
xhr.onload = () => {
progressBar.hidden = true;
compressBtn.textContent = 'Сжать';
compressBtn.disabled = false;
if (xhr.status === 200 && xhr.response && xhr.response.success) {
renderResults(xhr.response);
} else if (xhr.status === 429) {
showError('Слишком много запросов. Подождите минуту.');
} else {
const msg = xhr.response && xhr.response.error ? xhr.response.error : 'Ошибка при сжатии.';
showError(msg);
}
};
xhr.onerror = () => {
progressBar.hidden = true;
compressBtn.textContent = 'Сжать';
compressBtn.disabled = false;
showError('Ошибка сети. Повторите позже.');
};
xhr.send(formData);
});
function renderResults(data) {
const { stats, downloadUrl } = data;
// Summary
const totalOrig = stats.reduce((s, f) => s + f.originalSize, 0);
const totalComp = stats.reduce((s, f) => s + f.compressedSize, 0);
const totalSavings = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
$('summaryDetail').textContent = `${formatSize(totalOrig)} → ${formatSize(totalComp)}`;
const pctEl = $('summaryPercent');
pctEl.textContent = (totalSavings > 0 ? '-' : '') + totalSavings + '%';
pctEl.className = 'text-lg font-bold font-mono ' + savingsColor(totalSavings);
// Table
const tbody = $('statsBody');
tbody.innerHTML = '';
stats.forEach((f, i) => {
const row = document.createElement('tr');
row.className = 'border-b dark:border-surface-600/50 border-gray-100 last:border-0' + (i % 2 === 0 ? ' dark:bg-surface-700/20 bg-gray-50/50' : '');
const sColor = savingsColor(f.savings);
row.innerHTML = `
<td class="px-5 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600 truncate max-w-[180px]" title="${f.outputFilename}">${f.outputFilename}</td>
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-500 text-gray-400">${formatSize(f.originalSize)}</td>
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600">${formatSize(f.compressedSize)}</td>
<td class="px-5 py-2.5">
<div class="flex items-center gap-2 justify-end">
<div class="savings-bar flex-1 max-w-[60px]">
<div class="savings-bar-fill ${barColor(f.savings)}" style="width: ${Math.max(0, Math.min(100, f.savings))}%"></div>
</div>
<span class="font-mono text-xs font-medium ${sColor} w-10 text-right">${f.savings > 0 ? '-' : ''}${f.savings}%</span>
</div>
</td>`;
tbody.appendChild(row);
fetch('/compress/single', { method: 'POST', body: formData })
.then(r => {
if (!r.ok) return r.json().then(j => Promise.reject(j.error || 'Ошибка ' + r.status));
return r.json();
})
.then(data => {
entry.status = 'done';
entry.result = data;
renderRowResult(entry, data);
batchFiles.push({ file: entry.file, result: data });
updateSummary();
})
.catch(err => {
entry.status = 'error';
renderRowError(entry, typeof err === 'string' ? err : 'Ошибка загрузки');
updateSummary();
})
.finally(() => {
activeUploads--;
drainQueue();
checkAllDone();
});
// Download
$('downloadBtn').href = downloadUrl;
results.classList.remove('hidden');
}
function savingsColor(pct) {
if (pct >= 40) return 'text-accent';
if (pct >= 15) return 'text-accent/70';
if (pct >= 0) return 'text-warn';
return 'text-danger';
function renderRowResult(entry, data) {
const row = entry.rowEl;
row.classList.remove('loading');
const resultEl = document.getElementById('result-' + entry.id);
if (!resultEl) return;
const pct = data.savings;
const cls = savingsClass(pct);
const sign = pct > 0 ? '' : (pct < 0 ? '+' : '');
const absPct = Math.abs(pct);
// Determine format badge for output
const outFmt = data.outputFilename ? data.outputFilename.split('.').pop().toUpperCase() : 'FILE';
resultEl.innerHTML =
'<div style="text-align:right">'
+ '<div class="savings-pct ' + cls + '">' + sign + absPct + '%</div>'
+ '<div class="new-size">' + fmtSize(data.compressedSize) + '</div>'
+ '</div>'
+ '<a href="' + escHtml(data.downloadUrl) + '" download="' + escHtml(data.outputFilename) + '" class="dl-btn">'
+ dlIcon() + outFmt
+ '</a>';
}
function barColor(pct) {
if (pct >= 40) return 'bg-accent';
if (pct >= 15) return 'bg-accent/60';
if (pct >= 0) return 'bg-warn';
return 'bg-danger';
function renderRowError(entry, msg) {
const row = entry.rowEl;
row.classList.remove('loading');
const resultEl = document.getElementById('result-' + entry.id);
if (!resultEl) return;
resultEl.innerHTML = '<span style="font-size:11px;color:#f44336;font-family:\'JetBrains Mono\',monospace" title="' + escHtml(msg) + '">Ошибка</span>';
}
function updateSummary() {
const done = fileEntries.filter(e => e.status === 'done' && e.result);
const total = fileEntries.length;
const processing = fileEntries.filter(e => e.status === 'pending' || e.status === 'uploading').length;
const titleEl = document.getElementById('summaryTitle');
const detailEl = document.getElementById('summaryDetail');
const savingsEl = document.getElementById('summarySavings');
if (processing > 0) {
titleEl.textContent = 'Обработка... (' + (total - processing) + '/' + total + ')';
} else {
titleEl.textContent = pluralFiles(total);
}
if (done.length > 0) {
const totalOrig = done.reduce((s, e) => s + e.result.originalSize, 0);
const totalComp = done.reduce((s, e) => s + e.result.compressedSize, 0);
const pct = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
detailEl.textContent = fmtSize(totalOrig) + ' → ' + fmtSize(totalComp);
const sign = pct > 0 ? '' : (pct < 0 ? '+' : '');
savingsEl.textContent = sign + Math.abs(pct) + '%';
savingsEl.className = 'summary-savings ' + savingsClass(pct);
} else {
detailEl.textContent = '';
savingsEl.textContent = '—';
}
}
function checkAllDone() {
const allFinished = fileEntries.every(e => e.status === 'done' || e.status === 'error');
const hasDone = fileEntries.some(e => e.status === 'done');
if (allFinished && hasDone && fileEntries.length > 1) {
dlAllBtn.classList.remove('hidden');
}
}
function pluralFiles(n) {
const m = n % 10, m100 = n % 100;
if (m === 1 && m100 !== 11) return n + ' файл';
if (m >= 2 && m <= 4 && (m100 < 12 || m100 > 14)) return n + ' файла';
return n + ' файлов';
}
// ── Batch download (re-upload to batch endpoint) ──────────────────────────────
dlAllBtn.addEventListener('click', async () => {
dlAllBtn.disabled = true;
dlAllBtn.textContent = 'Создаю архив...';
try {
const formData = new FormData();
batchFiles.forEach(b => formData.append('images', b.file));
formData.append('format', document.getElementById('format').value);
formData.append('quality', document.getElementById('quality').value);
let rv = document.getElementById('resize').value;
if (rv === 'custom') rv = '0';
formData.append('resize', rv);
const res = await fetch('/compress', { method: 'POST', body: formData });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || 'Ошибка ' + res.status);
}
const data = await res.json();
if (data.downloadUrl) {
const a = document.createElement('a');
a.href = data.downloadUrl;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
} catch(err) {
showError('Ошибка создания архива: ' + err.message);
} finally {
dlAllBtn.disabled = false;
dlAllBtn.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>Скачать всё архивом';
}
});
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Format Converter — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'converter';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,69 +4,47 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — Панель</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'home';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>
/* Dashboard header */
.dash-header { position:fixed; top:0; left:56px; right:0; height:52px; background:var(--surface-800); border-bottom:1px solid var(--surface-600); display:flex; align-items:center; padding:0 24px; z-index:40; backdrop-filter:blur(12px); }
.dash-logo { font-size:16px; font-weight:800; color:var(--text-primary); text-decoration:none; display:flex; align-items:center; gap:8px; flex-shrink:0; }
.dash-logo span { color:var(--accent); }
.dash-nav { display:flex; align-items:center; gap:4px; margin-left:28px; }
.dash-nav-link { font-size:13px; color:var(--text-muted); text-decoration:none; padding:6px 12px; border-radius:8px; transition:all .15s; font-weight:500; }
.dash-nav-link:hover { color:var(--text-secondary); background:rgba(255,255,255,.04); }
.dash-nav-link.active { color:var(--accent); background:var(--accent-bg); }
.dash-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
.dash-user { font-size:13px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; }
.dash-logout { color:var(--text-muted); transition:color .15s; }
.dash-logout:hover { color:var(--danger, #ff5252); }
@media(max-width:768px) {
.dash-header { left:0; }
.dash-nav { display:none; }
.dash-logo svg { display:none; }
}
.dash-logout:hover { color:#ff5252; }
@media(max-width:768px) { .dash-header { left:0; } .dash-logo svg { display:none; } }
@keyframes fadeUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
.fade-up { animation: fadeUp .4s ease-out forwards; }
.fade-d1 { animation: fadeUp .4s ease-out .05s forwards; opacity:0; }
.fade-d2 { animation: fadeUp .4s ease-out .1s forwards; opacity:0; }
.fade-d3 { animation: fadeUp .4s ease-out .15s forwards; opacity:0; }
.fade-d4 { animation: fadeUp .4s ease-out .2s forwards; opacity:0; }
.tool-tile { display:flex; flex-direction:column; gap:10px; padding:18px; border-radius:14px; border:1px solid var(--surface-600); background:var(--surface-800); color:inherit; text-decoration:none; transition:all .2s; }
.tool-tile:hover { transform:translateY(-2px); border-color:rgba(0,84,230,.3); box-shadow:0 8px 24px rgba(0,0,0,.1); background:var(--surface-700); }
.tile-icon { width:40px; height:40px; border-radius:10px; background:rgba(0,84,230,.1); display:flex; align-items:center; justify-content:center; color:#0054e6; flex-shrink:0; transition:background .2s; }
.tool-tile:hover .tile-icon { background:rgba(0,84,230,.18); }
.tile-icon { width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .2s; }
.tool-tile:hover .tile-icon { filter:brightness(1.2); }
.tile-title { font-size:14px; font-weight:600; color:var(--text-primary); }
.tile-desc { font-size:11px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; line-height:1.4; }
.cat-header { display:flex; align-items:center; gap:10px; margin-bottom:14px; margin-top:32px; }
.cat-header:first-of-type { margin-top:0; }
.cat-header:first-child { margin-top:0; }
.cat-icon { width:32px; height:32px; border-radius:8px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.cat-icon.images { background:rgba(0,84,230,.1); color:#0054e6; }
.cat-icon.code { background:rgba(34,197,94,.1); color:#22c55e; }
.cat-icon.web { background:rgba(168,85,247,.1); color:#a855f7; }
.cat-icon.utils { background:rgba(255,214,0,.1); color:#ffd600; }
.cat-title { font-size:16px; font-weight:700; color:var(--text-primary); }
.cat-count { font-size:11px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; background:var(--surface-600); padding:2px 8px; border-radius:6px; }
.cat-desc { font-size:12px; color:var(--text-muted); margin-left:auto; font-family:'JetBrains Mono',monospace; }
@media(max-width:640px) { .cat-desc { display:none; } }
</style>
</head>
<body class="font-sans antialiased">
<!-- Top header -->
<header class="dash-header">
<a href="/dashboard" class="dash-logo">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
WA Dev <span>Tools</span>
</a>
<nav class="dash-nav">
</nav>
<div class="dash-right">
<span class="dash-user" id="headerUser"></span>
<a href="/auth/logout" class="dash-logout" title="Выйти">
@ -78,142 +56,120 @@
<div class="main-content">
<div class="tool-container relative z-10 max-w-5xl mx-auto" style="padding-top:72px;">
<!-- Greeting -->
<div class="fade-up" style="margin-bottom:28px;">
<h1 style="font-size:1.75rem;font-weight:800;color:var(--text-primary);letter-spacing:-0.025em;" id="greeting">
Добро пожаловать
</h1>
<p style="font-size:14px;color:var(--text-secondary);margin-top:8px;line-height:1.6;max-width:600px;">
14 инструментов для веб-разработки: сжатие картинок и видео,
форматирование кода, тестирование API, генерация паролей и многое другое.
<h1 style="font-size:1.75rem;font-weight:800;color:var(--text-primary);letter-spacing:-0.025em;" id="greeting">Добро пожаловать</h1>
<p id="toolsCount" style="font-size:14px;color:var(--text-secondary);margin-top:8px;line-height:1.6;max-width:600px;">
Инструменты для веб-разработки: сжатие картинок и видео, форматирование кода, тестирование API и многое другое.
</p>
</div>
<!-- Category: Images -->
<div class="cat-header fade-d1">
<div class="cat-icon images">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>
</div>
<span class="cat-title">Изображения</span>
<span class="cat-count">5</span>
<span class="cat-desc">сжатие, видео, редактирование и генерация</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 fade-d1">
<a href="/compress" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></div>
<div><div class="tile-title">Сжатие</div><div class="tile-desc">Сжатие, ресайз, конвертация</div></div>
</a>
<a href="/placeholder" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 01-2.25 2.25H7.25A2.25 2.25 0 015 17v-2.5"/></svg></div>
<div><div class="tile-title">Placeholder</div><div class="tile-desc">Заглушки любого размера</div></div>
</a>
<a href="/svgeditor" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg></div>
<div><div class="tile-title">SVG-редактор</div><div class="tile-desc">Создание и оптимизация SVG</div></div>
</a>
<a href="/editor" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></svg></div>
<div><div class="tile-title">Фоторедактор</div><div class="tile-desc">Обработка фото в браузере</div></div>
</a>
<a href="/video" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/></svg></div>
<div><div class="tile-title">Видео</div><div class="tile-desc">Конвертация, сжатие, GIF</div></div>
</a>
</div>
<!-- Category: Code -->
<div class="cat-header fade-d2">
<div class="cat-icon code">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
</div>
<span class="cat-title">Код</span>
<span class="cat-count">3</span>
<span class="cat-desc">форматирование, очистка и конвертация</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 fade-d2">
<a href="/formatter" class="tool-tile">
<div class="tile-icon" style="background:rgba(34,197,94,.1);color:#22c55e;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg></div>
<div><div class="tile-title">Форматирование</div><div class="tile-desc">CSS, JS — beautify кода</div></div>
</a>
<a href="/sanitizer" class="tool-tile">
<div class="tile-icon" style="background:rgba(34,197,94,.1);color:#22c55e;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg></div>
<div><div class="tile-title">HTML Sanitizer</div><div class="tile-desc">Очистка HTML, entities, текст</div></div>
</a>
<a href="/converter" class="tool-tile">
<div class="tile-icon" style="background:rgba(34,197,94,.1);color:#22c55e;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/></svg></div>
<div><div class="tile-title">Конвертер</div><div class="tile-desc">HTML ↔ Markdown</div></div>
</a>
</div>
<!-- Category: Web -->
<div class="cat-header fade-d3">
<div class="cat-icon web">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/></svg>
</div>
<span class="cat-title">Веб</span>
<span class="cat-count">3</span>
<span class="cat-desc">HTTP-клиент, парсер и анализ</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 fade-d3">
<a href="/parser" class="tool-tile">
<div class="tile-icon" style="background:rgba(168,85,247,.1);color:#a855f7;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L6.257 6.514a4.5 4.5 0 001.242 7.244"/></svg></div>
<div><div class="tile-title">Парсер статей</div><div class="tile-desc">Извлечение контента из URL</div></div>
</a>
<a href="/httpclient" class="tool-tile">
<div class="tile-icon" style="background:rgba(168,85,247,.1);color:#a855f7;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/></svg></div>
<div><div class="tile-title">HTTP-клиент</div><div class="tile-desc">Тест API — Postman lite</div></div>
</a>
<a href="/redirects" class="tool-tile">
<div class="tile-icon" style="background:rgba(168,85,247,.1);color:#a855f7;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg></div>
<div><div class="tile-title">Redirect-анализатор</div><div class="tile-desc">Цепочки HTTP-редиректов</div></div>
</a>
</div>
<!-- Category: Utils -->
<div class="cat-header fade-d4">
<div class="cat-icon utils">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/></svg>
</div>
<span class="cat-title">Утилиты</span>
<span class="cat-count">3</span>
<span class="cat-desc">пароли, PDF, Markdown</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 fade-d4">
<a href="/password" class="tool-tile">
<div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg></div>
<div><div class="tile-title">Генератор паролей</div><div class="tile-desc">Криптографически стойкая генерация</div></div>
</a>
<a href="/pdf" class="tool-tile"> <div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></div> <div><div class="tile-title">PDF инструменты</div><div class="tile-desc">Объединение, сжатие, конвертация</div></div> </a>
<a href="/md" class="tool-tile">
<div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></div>
<div><div class="tile-title">Markdown Viewer</div><div class="tile-desc">Просмотр и экспорт .md файлов</div></div>
</a>
</div>
<div id="toolsGrid"></div>
</div>
</div>
<script>
// Personalized greeting + header user
// Category icons (SVG paths)
const CAT_ICONS = {
images: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/>',
code: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>',
web: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/>',
utils: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>',
};
// Tool icons — match sidebar icons from shared.js
const TOOL_ICONS = {
compress: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/>',
placeholder: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 01-2.25 2.25H7.25A2.25 2.25 0 015 17v-2.5"/>',
svgeditor: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/>',
editor: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/>',
video: '<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/>',
favicon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6z"/>',
formatter: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>',
sanitizer: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>',
converter: '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/>',
regex: '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/>',
parser: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L6.257 6.514a4.5 4.5 0 001.242 7.244"/>',
httpclient: '<path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/>',
redirects: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>',
password: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/>',
md: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>',
pdf: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>',
};
function svgIcon(pathHtml, size) {
return `<svg width="${size||20}" height="${size||20}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">${pathHtml}</svg>`;
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Load tools from API
fetch('/api/tools')
.then(r => r.json())
.then(categories => {
const grid = document.getElementById('toolsGrid');
let totalTools = 0;
const delays = ['', 'fade-up', 'fade-up', 'fade-up', 'fade-up'];
let catIdx = 0;
categories.forEach(cat => {
if (!cat.tools || !cat.tools.length) return;
catIdx++;
totalTools += cat.tools.length;
const color = cat.color || '#0054e6';
const bgColor = color + '1a'; // 10% opacity hex
// Category header
const header = document.createElement('div');
header.className = 'cat-header fade-up';
header.style.animationDelay = (catIdx * 0.05) + 's';
header.innerHTML = `
<div class="cat-icon" style="background:${bgColor};color:${color}">
${svgIcon(CAT_ICONS[cat.slug] || CAT_ICONS.utils, 18)}
</div>
<span class="cat-title">${esc(cat.title)}</span>
<span class="cat-count">${cat.tools.length}</span>
<span class="cat-desc">${esc(cat.description || '')}</span>
`;
grid.appendChild(header);
// Tools grid
const tilesWrap = document.createElement('div');
tilesWrap.className = 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 fade-up';
tilesWrap.style.animationDelay = (catIdx * 0.05) + 's';
cat.tools.forEach(tool => {
const iconPath = TOOL_ICONS[tool.slug] || TOOL_ICONS.compress;
const a = document.createElement('a');
a.href = tool.path;
a.className = 'tool-tile';
a.innerHTML = `
<div class="tile-icon" style="background:${bgColor};color:${color}">${svgIcon(iconPath)}</div>
<div>
<div class="tile-title">${esc(tool.title)}</div>
<div class="tile-desc">${esc(tool.description || '')}</div>
</div>
`;
tilesWrap.appendChild(a);
});
grid.appendChild(tilesWrap);
});
// Update count
document.getElementById('toolsCount').textContent =
totalTools + ' инструментов для веб-разработки: сжатие картинок и видео, форматирование кода, тестирование API и многое другое.';
})
.catch(() => {
document.getElementById('toolsGrid').innerHTML = '<p style="color:var(--text-muted)">Не удалось загрузить инструменты</p>';
});
// User greeting
fetch('/auth/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) return;
const name = user.name || user.email.split('@')[0];
const el = document.getElementById('greeting');
if (el) el.textContent = 'Привет, ' + name;
const hu = document.getElementById('headerUser');
if (hu) hu.textContent = name;
}).catch(() => {});
// Load about section from DB
fetch('/api/content/dashboard').then(r => r.ok ? r.json() : []).then(blocks => {
const about = blocks.find(b => b.block_key === 'about');
if (about) {
const h = document.querySelector('.about-card h2');
const p = document.querySelector('.about-card p');
if (h) h.textContent = about.title;
if (p) p.textContent = about.body;
}
document.getElementById('greeting').textContent = 'Привет, ' + name;
document.getElementById('headerUser').textContent = name;
}).catch(() => {});
</script>
<!-- Yandex.Metrika counter -->

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Editor — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'editor';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

325
public/favicon.html Normal file
View File

@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Генератор Favicon — WA Dev Tools</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'favicon';</script>
<style>
/* ── Dropzone ── */
.dropzone-wrap { position: relative; border: 2px dashed var(--surface-600); border-radius: 14px; padding: 48px 20px; text-align: center; cursor: pointer; transition: border-color .25s, background .25s; }
.dropzone-wrap:hover, .dropzone-wrap.active { border-color: var(--accent); background: var(--accent-bg); }
.dropzone-wrap .drop-icon { transition: transform .3s cubic-bezier(.34,1.56,.64,1); }
.dropzone-wrap.active .drop-icon { transform: scale(1.12) translateY(-4px); }
/* ── Size grid ── */
.sizes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 12px; margin-top: 20px; }
.size-card { background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 10px; padding: 10px 8px; text-align: center; }
.size-card img { display: block; margin: 0 auto 6px; border-radius: 4px; border: 1px solid var(--surface-600); image-rendering: pixelated; background: repeating-conic-gradient(var(--surface-700) 0% 25%, transparent 0% 50%) 0 0 / 10px 10px; }
.size-label { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); font-weight: 600; }
/* ── Code blocks ── */
.code-block-wrap { position: relative; margin-top: 16px; }
.code-block { background: var(--surface-900); border: 1px solid var(--surface-600); border-radius: 10px; padding: 14px 44px 14px 16px; font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary); white-space: pre; overflow-x: auto; line-height: 1.7; }
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 10px; border-radius: 6px; font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .04em; background: var(--surface-700); color: var(--text-muted); border: 1px solid var(--surface-600); cursor: pointer; transition: background .2s, color .2s; white-space: nowrap; }
.copy-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.copy-btn.copied { background: #4caf50; color: #fff; border-color: #4caf50; }
/* ── Section header ── */
.section-title { font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-bottom: 8px; margin-top: 24px; }
/* ── Download button ── */
.dl-all-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 14px; background: var(--accent); color: #fff; border: none; border-radius: 12px; font-size: 14px; font-weight: 700; cursor: pointer; transition: background .2s, box-shadow .2s; margin-top: 20px; text-decoration: none; }
.dl-all-btn:hover { background: var(--accent-dim); box-shadow: 0 6px 20px rgba(0,84,230,.25); }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width: 22px; height: 22px; border: 3px solid var(--surface-600); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; margin: 0 auto; }
/* ── Error / hidden ── */
.error-block { background: rgba(244,67,54,.08); border: 1px solid rgba(244,67,54,.2); border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #f44336; margin-top: 14px; }
.hidden { display: none !important; }
/* ── Animations ── */
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp .35s ease-out forwards; }
.fade-up-delay { animation: fadeUp .35s ease-out .1s forwards; opacity: 0; }
/* ── Uploading overlay ── */
.upload-state { padding: 32px 0; text-align: center; }
.upload-state p { font-size: 13px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; margin-top: 14px; }
@media (max-width: 500px) {
.sizes-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<div class="main-content">
<div class="tool-container relative z-10 max-w-2xl mx-auto py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<div class="page-header" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
FAVICON
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Генератор <span class="text-accent">Favicon</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">PNG · Apple Touch · Android · Manifest · HTML теги</p>
</div>
</div>
<!-- Main card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<!-- Drop zone -->
<div id="dropzone" class="dropzone-wrap" role="button" tabindex="0" aria-label="Зона загрузки изображения">
<div class="drop-icon mb-3">
<svg class="mx-auto w-10 h-10" style="color:var(--text-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
</div>
<p style="font-size:14px;color:var(--text-muted)">
Перетащите изображение или <span style="color:var(--accent);font-weight:600">выберите файл</span>
</p>
<p style="font-size:11px;color:var(--text-muted);margin-top:5px;font-family:'JetBrains Mono',monospace">PNG или SVG рекомендуется · любой формат · до 5 МБ</p>
<input type="file" accept="image/*" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
</div>
</div>
<!-- Error block -->
<div id="errorBlock" class="error-block hidden"></div>
<!-- Upload progress -->
<div id="uploadState" class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm shadow-sm dark:shadow-none mt-4 hidden">
<div class="upload-state">
<div class="spinner"></div>
<p>Генерация иконок...</p>
</div>
</div>
<!-- Results -->
<div id="results" class="hidden">
<!-- Preview grid -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm shadow-sm dark:shadow-none mt-4">
<div class="section-title">Сгенерированные иконки</div>
<div id="sizesGrid" class="sizes-grid"></div>
<!-- HTML tags -->
<div class="section-title">HTML теги</div>
<div class="code-block-wrap">
<pre id="htmlTagsBlock" class="code-block"></pre>
<button class="copy-btn" onclick="copyCode('htmlTagsBlock', this)">Копировать</button>
</div>
<!-- Manifest -->
<div class="section-title">site.webmanifest</div>
<div class="code-block-wrap">
<pre id="manifestBlock" class="code-block"></pre>
<button class="copy-btn" onclick="copyCode('manifestBlock', this)">Копировать</button>
</div>
<!-- Download ZIP -->
<a id="dlBtn" href="#" download class="dl-all-btn">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Скачать все иконки (ZIP)
</a>
<!-- Reset -->
<button id="resetBtn" type="button" onclick="resetTool()" style="display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:10px;background:transparent;color:var(--text-muted);border:1px solid var(--surface-600);border-radius:12px;font-size:12px;font-weight:600;font-family:'JetBrains Mono',monospace;cursor:pointer;margin-top:10px;transition:border-color .2s,color .2s;">
Загрузить другое изображение
</button>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// ── DOM refs ──────────────────────────────────────────────────────────────────
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const errorBlock = document.getElementById('errorBlock');
const uploadState = document.getElementById('uploadState');
const results = document.getElementById('results');
const sizesGrid = document.getElementById('sizesGrid');
const htmlTagsBlock = document.getElementById('htmlTagsBlock');
const manifestBlock = document.getElementById('manifestBlock');
const dlBtn = document.getElementById('dlBtn');
// ── Dropzone events ───────────────────────────────────────────────────────────
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.closest('.dark\\:bg-surface-800\\/80, .bg-white\\/85') || dropzone; dropzone.classList.add('active'); });
dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('active'); });
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('active'); handleFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', e => { handleFile(e.target.files[0]); e.target.value = ''; });
// ── Error helper ──────────────────────────────────────────────────────────────
function showError(msg) {
errorBlock.textContent = msg;
errorBlock.classList.remove('hidden');
setTimeout(() => errorBlock.classList.add('hidden'), 7000);
}
// ── File handling ─────────────────────────────────────────────────────────────
function handleFile(file) {
if (!file) return;
if (!file.type.startsWith('image/')) {
showError('Допустимы только изображения (PNG, SVG, JPEG, WebP, ...)');
return;
}
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой. Максимум 5 МБ.');
return;
}
uploadFile(file);
}
// ── Upload & generate ─────────────────────────────────────────────────────────
async function uploadFile(file) {
errorBlock.classList.add('hidden');
results.classList.add('hidden');
uploadState.classList.remove('hidden');
dropzone.closest('.dark\\:bg-surface-800\\/80') && (dropzone.parentElement.style.pointerEvents = 'none');
const formData = new FormData();
formData.append('image', file);
try {
const resp = await fetch('/favicon/generate', { method: 'POST', body: formData });
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка ' + resp.status);
}
renderResults(file, data);
} catch (err) {
showError('Ошибка: ' + err.message);
} finally {
uploadState.classList.add('hidden');
if (dropzone.parentElement) dropzone.parentElement.style.pointerEvents = '';
}
}
// ── Render results ────────────────────────────────────────────────────────────
async function renderResults(originalFile, data) {
// Build size preview grid
sizesGrid.innerHTML = '';
// Read original file as data URL for client-side preview rendering
const origDataUrl = await fileToDataUrl(originalFile);
for (const spec of data.sizes) {
const card = document.createElement('div');
card.className = 'size-card';
// Render preview using canvas (avoids round-trip for thumbnails)
const canvas = document.createElement('canvas');
const displaySize = Math.min(spec.size, 64); // cap display at 64px
canvas.width = displaySize;
canvas.height = displaySize;
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = origDataUrl;
await new Promise(resolve => { img.onload = resolve; img.onerror = resolve; });
ctx.drawImage(img, 0, 0, displaySize, displaySize);
const previewImg = document.createElement('img');
previewImg.src = canvas.toDataURL('image/png');
previewImg.width = displaySize;
previewImg.height = displaySize;
previewImg.alt = spec.filename;
const label = document.createElement('div');
label.className = 'size-label';
label.textContent = spec.size + '\u00d7' + spec.size;
card.appendChild(previewImg);
card.appendChild(label);
sizesGrid.appendChild(card);
}
// Code blocks
htmlTagsBlock.textContent = data.htmlTags;
manifestBlock.textContent = data.manifestJson;
// Download link
dlBtn.href = data.downloadUrl;
results.classList.remove('hidden');
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ── Copy to clipboard ─────────────────────────────────────────────────────────
function copyCode(blockId, btn) {
const text = document.getElementById(blockId).textContent;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = orig;
btn.classList.remove('copied');
}, 2000);
}).catch(() => {
// Fallback for older browsers
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
});
}
// ── Reset ─────────────────────────────────────────────────────────────────────
function resetTool() {
results.classList.add('hidden');
errorBlock.classList.add('hidden');
sizesGrid.innerHTML = '';
htmlTagsBlock.textContent = '';
manifestBlock.textContent = '';
dlBtn.href = '#';
fileInput.value = '';
}
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Formatter — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'formatter';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTTP Client — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'httpclient';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
@ -211,7 +211,22 @@
@media(max-width: 900px) {
.panels-grid { grid-template-columns: 1fr; }
}
/* AI Security Audit */
.ai-audit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--accent); color: var(--accent); background: transparent; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; cursor: pointer; transition: all .2s; white-space: nowrap; }
.ai-audit-btn:hover { background: var(--accent); color: #fff; }
.ai-audit-btn:disabled { opacity: .5; cursor: wait; }
.ai-result-panel { margin-top: 8px; padding: 14px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; line-height: 1.7; color: var(--text-primary); max-height: 400px; overflow-y: auto; }
.ai-result-panel h1,.ai-result-panel h2,.ai-result-panel h3 { font-weight: 700; margin: .6em 0 .3em; font-size: 1.1em; }
.ai-result-panel code { background: var(--surface-800); padding: 1px 5px; border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: .9em; }
.ai-result-panel ul,.ai-result-panel ol { padding-left: 1.5em; margin: .3em 0; }
.ai-result-panel li { margin: .15em 0; }
.ai-result-panel p { margin: .3em 0; }
.ai-result-panel table { border-collapse: collapse; width: 100%; margin: .5em 0; font-size: .9em; }
.ai-result-panel th,.ai-result-panel td { border: 1px solid var(--surface-600); padding: 4px 8px; text-align: left; }
.ai-result-panel th { background: var(--surface-800); }
</style>
<script src="/vendor/marked.min.js"></script>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
@ -343,6 +358,15 @@
</div>
</div>
<!-- Response Headers -->
<!-- AI Security Audit -->
<div id="aiAuditWrap" class="hidden px-3 py-2 border-t border-surface-600">
<button id="aiAuditBtn" class="ai-audit-btn" type="button" onclick="aiSecurityAudit()">
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
AI Security Audit
</button>
<div id="aiAuditResult" class="ai-result-panel hidden"></div>
</div>
<div id="resTab-headers" class="hidden p-3" style="max-height:480px;overflow:auto">
<div id="responseHeadersContent" class="text-xs font-mono space-y-1"></div>
</div>
@ -595,6 +619,20 @@
});
};
// Store response data for AI audit
window._lastResponse = {
status: data.status,
statusText: data.statusText,
headers: data.headers,
body: (data.body || '').slice(0, 2000),
url: data.url || req.url,
method: req.method,
time: data.time,
};
// Show AI audit button
document.getElementById('aiAuditWrap').classList.remove('hidden');
document.getElementById('aiAuditResult').classList.add('hidden');
addToHistory(req.method, req.url, data.status, data.time);
} catch (err) {
statusBar.innerHTML = `<span class="text-xs font-mono" style="color:#ff5252">Ошибка соединения: ${escHtml(err.message)}</span>`;
@ -700,6 +738,83 @@
renderHistory();
})();
// ── AI Security Audit ─────────────────────────────────────────────────────────
async function aiSecurityAudit() {
const resp = window._lastResponse;
if (!resp) return;
const btn = document.getElementById('aiAuditBtn');
const resultDiv = document.getElementById('aiAuditResult');
btn.disabled = true;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" style="animation:spin .7s linear infinite"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg> Анализирую...';
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '';
// Build headers summary
const hdrs = resp.headers || {};
const hdrLines = Object.entries(hdrs).map(([k,v]) => k + ': ' + v).join('\n');
const payload = {
pattern: `${resp.method} ${resp.url} → ${resp.status} ${resp.statusText}
Response Headers:
${hdrLines}
Response Body (first 500 chars):
${(resp.body || '').slice(0, 500)}`,
flags: 'security-audit'
};
let fullText = '';
try {
const res = await fetch('/api/ai/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Ошибка ' + res.status);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const d = line.slice(6).trim();
if (d === '[DONE]') continue;
try {
const parsed = JSON.parse(d);
if (parsed.error) throw new Error(parsed.error);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
let md = fullText.replace(/^```[a-z]*\n?/gm, '').replace(/```\s*$/gm, '').trim();
resultDiv.innerHTML = marked.parse(md);
}
} catch (e) {
if (e.message && !e.message.includes('JSON')) throw e;
}
}
}
if (!fullText.trim()) resultDiv.innerHTML = '<span style="color:var(--text-muted)">Пустой ответ</span>';
} catch (err) {
resultDiv.innerHTML = '<span style="color:#f44336">' + (err.message || 'AI недоступен') + '</span>';
} finally {
btn.disabled = false;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg> AI Security Audit';
}
}
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">

View File

@ -12,25 +12,9 @@
<meta name="description" content="Сжатие картинок, форматирование кода, HTTP-клиент и ещё 10 инструментов для веб-разработчиков — всё в браузере, " />
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
fontFamily: { mono: ['JetBrains Mono', 'monospace'], sans: ['Manrope', 'sans-serif'] },
colors: { surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' }, accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' } }
}}
};
</script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,16 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Вход — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
fontFamily: { mono: ['JetBrains Mono', 'monospace'], sans: ['Manrope', 'sans-serif'] },
colors: { surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' }, accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' } }
}}
};
</script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Viewer — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'md';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
@ -231,19 +231,27 @@ document.getElementById('copyHtmlBtn').addEventListener('click', () => {
// Download HTML
document.getElementById('downloadHtmlBtn').addEventListener('click', () => {
const body = document.getElementById('mdOutput').innerHTML;
const fullHtml = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n<title>Markdown</title>\n<style>\nbody{font-family:sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#333}\npre{background:#f5f5f5;padding:1em;border-radius:6px;overflow-x:auto}\ncode{font-family:monospace;font-size:.9em;background:#f0f0f0;padding:2px 5px;border-radius:3px}\npre code{background:none;padding:0}\ntable{border-collapse:collapse;width:100%}\nth,td{border:1px solid #ddd;padding:8px;text-align:left}\nth{background:#f5f5f5}\nblockquote{border-left:3px solid #0054e6;padding:.5em 1em;margin:1em 0;color:#666;background:#f9f9f9}\nimg{max-width:100%}\n</style>\n</head>\n<body>\n' + body + '\n<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>\n</html>';
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Markdown</title>
<style>
body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#333}
pre{background:#f5f5f5;padding:1em;border-radius:6px;overflow-x:auto}
code{font-family:monospace;font-size:.9em;background:#f0f0f0;padding:2px 5px;border-radius:3px}
pre code{background:none;padding:0}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ddd;padding:8px;text-align:left}
th{background:#f5f5f5}
blockquote{border-left:3px solid #0054e6;padding:.5em 1em;margin:1em 0;color:#666;background:#f9f9f9}
img{max-width:100%}
</style>
</head>
<body>
${body}
</body>
</html>`;
const blob = new Blob([fullHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Парсер статей — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'parser';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Генератор паролей — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — PDF инструменты</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'pdf';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Placeholder — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'placeholder';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirect Analyzer — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'redirects';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
@ -233,7 +233,19 @@
}
.url-text a { color: #6b9fff; text-decoration: none; }
.url-text a:hover { text-decoration: underline; }
/* AI Security Audit */
.ai-audit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--accent); color: var(--accent); background: transparent; font-size: 12px; font-weight: 700; font-family: 'JetBrains Mono', monospace; cursor: pointer; transition: all .2s; }
.ai-audit-btn:hover { background: var(--accent); color: #fff; }
.ai-audit-btn:disabled { opacity: .5; cursor: wait; }
.ai-result-panel { margin-top: 10px; padding: 14px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; line-height: 1.7; color: var(--text-primary); }
.ai-result-panel h1,.ai-result-panel h2,.ai-result-panel h3 { font-weight: 700; margin: .6em 0 .3em; font-size: 1.1em; }
.ai-result-panel code { background: var(--surface-800); padding: 1px 5px; border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: .9em; }
.ai-result-panel ul,.ai-result-panel ol { padding-left: 1.5em; margin: .3em 0; }
.ai-result-panel li { margin: .15em 0; }
.ai-result-panel p { margin: .3em 0; }
</style>
<script src="/vendor/marked.min.js"></script>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
@ -326,6 +338,15 @@
</div>
<!-- Headers detail -->
<!-- AI Security Audit -->
<div id="aiAuditWrap" class="hidden mt-4">
<button id="aiAuditBtn" class="ai-audit-btn" type="button" onclick="aiSecurityAudit()">
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
AI Security Audit
</button>
<div id="aiAuditResult" class="ai-result-panel hidden"></div>
</div>
<details class="details-accordion" id="headersAccordion">
<summary>
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/></svg>
@ -536,6 +557,12 @@
renderIssues(data);
renderChain(data);
renderHeaders(data);
// Store for AI audit
window._lastRedirectData = data;
document.getElementById('aiAuditWrap').classList.remove('hidden');
document.getElementById('aiAuditResult').classList.add('hidden');
showState('result');
} catch (err) {
@ -599,6 +626,84 @@
if (qp.get('url')) { urlInput.value = qp.get('url'); analyze(); }
})();
// ── AI Security Audit for Redirects ───────────────────────────────────────────
async function aiSecurityAudit() {
const data = window._lastRedirectData;
if (!data || !data.chain) return;
const btn = document.getElementById('aiAuditBtn');
const resultDiv = document.getElementById('aiAuditResult');
btn.disabled = true;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" style="animation:spin .7s linear infinite"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg> Анализирую...';
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '';
// Build chain summary for AI
const chainInfo = data.chain.map((step, i) => {
const hdrs = Object.entries(step.headers || {}).map(([k,v]) => ' ' + k + ': ' + v).join('\n');
return `Step ${i+1}: ${step.status} ${step.statusText} ${step.url}\n${hdrs}`;
}).join('\n\n');
const payload = {
pattern: `Redirect chain analysis:
Total steps: ${data.chain.length}
Total time: ${data.total_time}ms
Loop detected: ${data.loop_detected ? 'YES' : 'no'}
Issues: ${(data.issues || []).join(', ') || 'none'}
${chainInfo}`,
flags: 'security-audit'
};
let fullText = '';
try {
const res = await fetch('/api/ai/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Ошибка ' + res.status);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const d = line.slice(6).trim();
if (d === '[DONE]') continue;
try {
const parsed = JSON.parse(d);
if (parsed.error) throw new Error(parsed.error);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
let md = fullText.replace(/^```[a-z]*\n?/gm, '').replace(/```\s*$/gm, '').trim();
resultDiv.innerHTML = marked.parse(md);
}
} catch (e) {
if (e.message && !e.message.includes('JSON')) throw e;
}
}
}
if (!fullText.trim()) resultDiv.innerHTML = '<span style="color:var(--text-muted)">Пустой ответ</span>';
} catch (err) {
resultDiv.innerHTML = '<span style="color:#f44336">' + (err.message || 'AI недоступен') + '</span>';
} finally {
btn.disabled = false;
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg> AI Security Audit';
}
}
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">

610
public/regex.html Normal file
View File

@ -0,0 +1,610 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regex Tester — WA Dev Tools</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'regex';</script>
<style>
/* ── Layout ── */
.regex-layout { display: flex; flex-direction: column; gap: 16px; }
/* ── Pattern row ── */
.pattern-row { display: flex; align-items: center; gap: 0; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 10px; overflow: hidden; transition: border-color .2s; }
.pattern-row:focus-within { border-color: var(--accent); }
.pattern-row.error { border-color: #f44336; }
.delimiter { padding: 0 12px; font-size: 18px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-weight: 300; line-height: 44px; user-select: none; flex-shrink: 0; }
.pattern-input { flex: 1; padding: 10px 0; background: transparent; border: none; outline: none; font-size: 15px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary); min-width: 0; }
.pattern-input::placeholder { color: var(--text-muted); }
.flags-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.flag-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .04em; cursor: pointer; user-select: none; transition: background .15s, color .15s, border-color .15s; border: 1px solid var(--surface-600); background: var(--surface-800); color: var(--text-muted); }
.flag-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.flag-pill:hover:not(.active) { border-color: var(--accent); color: var(--accent); }
.flag-desc { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; margin-left: 4px; }
/* ── Match count badge ── */
.match-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; background: rgba(0,84,230,.12); color: var(--accent); border: 1px solid rgba(0,84,230,.2); }
.match-badge.no-match { background: rgba(128,128,128,.1); color: var(--text-muted); border-color: var(--surface-600); }
.match-badge.error-badge { background: rgba(244,67,54,.1); color: #f44336; border-color: rgba(244,67,54,.2); }
/* ── Test area with highlights ── */
.test-area-wrap { position: relative; }
.test-textarea { width: 100%; min-height: 180px; padding: 14px; background: var(--surface-900); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary); resize: vertical; outline: none; transition: border-color .2s; line-height: 1.7; box-sizing: border-box; }
.test-textarea:focus { border-color: var(--accent); }
/* Overlay for highlights — sits on top of textarea */
.highlight-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 14px; font-size: 13px; font-family: 'JetBrains Mono', monospace; line-height: 1.7; pointer-events: none; white-space: pre-wrap; word-break: break-all; overflow: hidden; box-sizing: border-box; color: transparent; border-radius: 10px; }
/* Highlight colors cycling through groups */
.hl-0 { background: rgba(0,188,212,.28); border-radius: 2px; }
.hl-1 { background: rgba(156,39,176,.28); border-radius: 2px; }
.hl-2 { background: rgba(255,152,0,.28); border-radius: 2px; }
.hl-3 { background: rgba(76,175,80,.28); border-radius: 2px; }
.hl-4 { background: rgba(233,30,99,.28); border-radius: 2px; }
.hl-wrap { position: relative; overflow: hidden; border-radius: 10px; }
.hl-wrap textarea { position: relative; z-index: 1; background: transparent !important; caret-color: var(--text-primary); }
.hl-wrap .highlight-overlay { z-index: 0; background: var(--surface-900); }
/* ── Error message ── */
.regex-error { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: #f44336; margin-top: 6px; min-height: 16px; }
/* ── Match list ── */
.match-list { border: 1px solid var(--surface-600); border-radius: 10px; overflow: hidden; max-height: 320px; overflow-y: auto; }
.match-list::-webkit-scrollbar { width: 4px; }
.match-list::-webkit-scrollbar-thumb { background: var(--surface-600); border-radius: 2px; }
.match-item { padding: 10px 14px; border-bottom: 1px solid var(--surface-600); font-size: 12px; }
.match-item:last-child { border-bottom: none; }
.match-item:hover { background: var(--accent-bg); }
.match-num { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-muted); font-weight: 700; margin-bottom: 4px; }
.match-text { font-family: 'JetBrains Mono', monospace; font-size: 13px; color: var(--text-primary); background: rgba(0,84,230,.1); padding: 2px 6px; border-radius: 4px; display: inline-block; word-break: break-all; }
.match-groups { margin-top: 5px; display: flex; flex-wrap: wrap; gap: 4px; }
.group-tag { font-size: 10px; font-family: 'JetBrains Mono', monospace; padding: 1px 7px; border-radius: 10px; background: var(--surface-700); color: var(--text-muted); }
.group-val { font-weight: 600; color: var(--text-primary); }
.match-empty { padding: 24px; text-align: center; font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
/* ── Quick ref ── */
.quick-ref-toggle { display: flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted); padding: 0; transition: color .2s; }
.quick-ref-toggle:hover { color: var(--accent); }
.quick-ref-toggle svg { transition: transform .2s; }
.quick-ref-toggle.open svg { transform: rotate(90deg); }
.quick-ref-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; margin-top: 12px; }
.ref-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 6px; cursor: pointer; transition: border-color .15s; }
.ref-item:hover { border-color: var(--accent); }
.ref-token { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; min-width: 52px; }
.ref-desc { font-size: 11px; color: var(--text-muted); line-height: 1.4; }
/* ── Section labels ── */
.section-title { font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-bottom: 8px; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
/* ── Animations ── */
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp .35s ease-out forwards; }
.fade-up-delay { animation: fadeUp .35s ease-out .1s forwards; opacity: 0; }
@media (max-width: 600px) {
.flag-desc { display: none; }
.quick-ref-grid { grid-template-columns: 1fr 1fr; }
}
/* AI Explain */
.ai-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--accent); color: var(--accent); background: transparent; font-size: 12px; font-weight: 700; font-family: 'JetBrains Mono', monospace; cursor: pointer; transition: all .2s; margin-left: 12px; }
.ai-btn:hover { background: var(--accent); color: #fff; }
.ai-btn:disabled { opacity: .5; cursor: wait; }
.ai-result { margin-top: 14px; padding: 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; line-height: 1.7; color: var(--text-primary); }
.ai-result.hidden { display: none; }
.ai-result h1,.ai-result h2,.ai-result h3 { font-weight: 700; margin: .8em 0 .3em; }
.ai-result code { background: var(--surface-800); padding: 1px 5px; border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: .9em; }
.ai-result ul,.ai-result ol { padding-left: 1.5em; margin: .4em 0; }
.ai-result li { margin: .2em 0; }
.ai-result p { margin: .4em 0; }
</style>
<script src="/vendor/marked.min.js"></script>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<div class="main-content">
<div class="tool-container relative z-10 max-w-3xl mx-auto py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<div class="page-header" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
REGEX
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Regex <span class="text-accent">Tester</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Тестирование регулярных выражений · JavaScript · 100% offline</p>
</div>
</div>
<!-- Main card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<div class="regex-layout">
<!-- Pattern input -->
<div>
<div class="section-title">Регулярное выражение</div>
<div id="patternRow" class="pattern-row">
<span class="delimiter">/</span>
<input id="patternInput" class="pattern-input" type="text" placeholder="введите паттерн" autocomplete="off" autocorrect="off" spellcheck="false" />
<span class="delimiter">/</span>
<span id="flagsDisplay" class="delimiter" style="padding-left:0;min-width:32px;color:var(--accent);font-size:14px;">g</span>
</div>
<div id="regexError" class="regex-error"></div>
<!-- Flags -->
<div class="flags-row">
<span style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;margin-right:2px;">Флаги:</span>
<button class="flag-pill active" data-flag="g" title="Глобальный поиск — все совпадения">g <span class="flag-desc">global</span></button>
<button class="flag-pill" data-flag="i" title="Регистронезависимый поиск">i <span class="flag-desc">ignore case</span></button>
<button class="flag-pill" data-flag="m" title="Многострочный режим — ^ и $ для каждой строки">m <span class="flag-desc">multiline</span></button>
<button class="flag-pill" data-flag="s" title="Точка совпадает с переносом строки">s <span class="flag-desc">dotall</span></button>
<button class="flag-pill" data-flag="u" title="Режим Unicode">u <span class="flag-desc">unicode</span></button>
</div>
</div>
<!-- Test string area -->
<div>
<div class="section-header">
<div class="section-title" style="margin-bottom:0">Тестовая строка</div>
<div id="matchBadge" class="match-badge no-match">нет совпадений</div>
</div>
<div class="hl-wrap">
<div id="highlightOverlay" class="highlight-overlay" aria-hidden="true"></div>
<textarea id="testInput" class="test-textarea" placeholder="Введите текст для тестирования..." spellcheck="false"></textarea>
</div>
</div>
<!-- Match results -->
<div id="matchesSection">
<div class="section-title">Совпадения</div>
<div id="matchList" class="match-list">
<div class="match-empty">Введите паттерн и текст для поиска</div>
</div>
</div>
<!-- Quick reference -->
<div>
<button id="quickRefToggle" class="quick-ref-toggle" type="button" onclick="toggleQuickRef()">
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
Справка по синтаксису
</button>
<button id="aiExplainBtn" class="ai-btn" type="button" onclick="aiExplain()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg>
AI объяснение
</button>
<div id="quickRefPanel" style="display:none">
<div class="quick-ref-grid" style="margin-top:12px">
<div class="ref-item" onclick="insertToken('\\\\d')"><span class="ref-token">\d</span><span class="ref-desc">Цифра [0-9]</span></div>
<div class="ref-item" onclick="insertToken('\\\\D')"><span class="ref-token">\D</span><span class="ref-desc">Не цифра</span></div>
<div class="ref-item" onclick="insertToken('\\\\w')"><span class="ref-token">\w</span><span class="ref-desc">Слово [a-zA-Z0-9_]</span></div>
<div class="ref-item" onclick="insertToken('\\\\W')"><span class="ref-token">\W</span><span class="ref-desc">Не слово</span></div>
<div class="ref-item" onclick="insertToken('\\\\s')"><span class="ref-token">\s</span><span class="ref-desc">Пробел / таб / перенос</span></div>
<div class="ref-item" onclick="insertToken('\\\\S')"><span class="ref-token">\S</span><span class="ref-desc">Не пробел</span></div>
<div class="ref-item" onclick="insertToken('.')"><span class="ref-token">.</span><span class="ref-desc">Любой символ (кроме \n)</span></div>
<div class="ref-item" onclick="insertToken('^')"><span class="ref-token">^</span><span class="ref-desc">Начало строки</span></div>
<div class="ref-item" onclick="insertToken('$')"><span class="ref-token">$</span><span class="ref-desc">Конец строки</span></div>
<div class="ref-item" onclick="insertToken('*')"><span class="ref-token">*</span><span class="ref-desc">0 или более повторений</span></div>
<div class="ref-item" onclick="insertToken('+')"><span class="ref-token">+</span><span class="ref-desc">1 или более повторений</span></div>
<div class="ref-item" onclick="insertToken('?')"><span class="ref-token">?</span><span class="ref-desc">0 или 1 вхождение</span></div>
<div class="ref-item" onclick="insertToken('{n,m}')"><span class="ref-token">{n,m}</span><span class="ref-desc">От n до m повторений</span></div>
<div class="ref-item" onclick="insertToken('[abc]')"><span class="ref-token">[abc]</span><span class="ref-desc">Набор символов</span></div>
<div class="ref-item" onclick="insertToken('[^abc]')"><span class="ref-token">[^abc]</span><span class="ref-desc">Отрицание набора</span></div>
<div class="ref-item" onclick="insertToken('()')"><span class="ref-token">(...)</span><span class="ref-desc">Группа с захватом</span></div>
<div class="ref-item" onclick="insertToken('(?:)')"><span class="ref-token">(?:...)</span><span class="ref-desc">Группа без захвата</span></div>
<div class="ref-item" onclick="insertToken('(?=)')"><span class="ref-token">(?=...)</span><span class="ref-desc">Lookahead</span></div>
<div class="ref-item" onclick="insertToken('(?!)')"><span class="ref-token">(?!...)</span><span class="ref-desc">Negative lookahead</span></div>
<div class="ref-item" onclick="insertToken('|')"><span class="ref-token">a|b</span><span class="ref-desc">Альтернатива (или)</span></div>
<div class="ref-item" onclick="insertToken('\\\\b')"><span class="ref-token">\b</span><span class="ref-desc">Граница слова</span></div>
<div class="ref-item" onclick="insertToken('\\\\n')"><span class="ref-token">\n</span><span class="ref-desc">Перенос строки</span></div>
</div>
</div>
</div>
<div id="aiResult" class="ai-result hidden"></div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
pattern: '',
flags: new Set(['g']),
text: '',
regex: null,
matches: [],
error: null,
};
// ── DOM refs ──────────────────────────────────────────────────────────────────
const patternInput = document.getElementById('patternInput');
const patternRow = document.getElementById('patternRow');
const flagsDisplay = document.getElementById('flagsDisplay');
const regexError = document.getElementById('regexError');
const testInput = document.getElementById('testInput');
const highlightOverlay= document.getElementById('highlightOverlay');
const matchBadge = document.getElementById('matchBadge');
const matchList = document.getElementById('matchList');
// ── Flag toggles ──────────────────────────────────────────────────────────────
document.querySelectorAll('.flag-pill').forEach(btn => {
btn.addEventListener('click', () => {
const flag = btn.dataset.flag;
if (state.flags.has(flag)) {
state.flags.delete(flag);
btn.classList.remove('active');
} else {
state.flags.add(flag);
btn.classList.add('active');
}
updateFlagsDisplay();
scheduleUpdate();
});
});
function updateFlagsDisplay() {
const flagStr = [...state.flags].sort().join('');
flagsDisplay.textContent = flagStr || ' ';
}
// ── Input events with debounce ────────────────────────────────────────────────
let debounceTimer = null;
function scheduleUpdate() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runRegex, 150);
}
patternInput.addEventListener('input', () => {
state.pattern = patternInput.value;
scheduleUpdate();
});
testInput.addEventListener('input', () => {
state.text = testInput.value;
syncScroll();
scheduleUpdate();
});
testInput.addEventListener('scroll', syncScroll);
function syncScroll() {
highlightOverlay.scrollTop = testInput.scrollTop;
highlightOverlay.scrollLeft = testInput.scrollLeft;
}
// ── Core regex engine ─────────────────────────────────────────────────────────
function runRegex() {
state.error = null;
state.matches = [];
state.regex = null;
const pattern = state.pattern;
const text = state.text;
if (!pattern) {
renderHighlights([]);
renderMatches([]);
renderBadge(null, null);
patternRow.classList.remove('error');
regexError.textContent = '';
return;
}
let regex;
try {
const flagStr = [...state.flags].sort().join('');
// Ensure 'd' flag not used (not universally supported), use what we have
regex = new RegExp(pattern, flagStr);
state.regex = regex;
patternRow.classList.remove('error');
regexError.textContent = '';
} catch (err) {
state.error = err.message;
patternRow.classList.add('error');
regexError.textContent = err.message;
renderHighlights([]);
renderMatches([]);
renderBadge(null, true);
return;
}
// Collect matches — handle non-global to avoid infinite loop
const matches = [];
if (state.flags.has('g') || state.flags.has('y')) {
// Reset lastIndex defensively
regex.lastIndex = 0;
let m;
let safety = 0;
while ((m = regex.exec(text)) !== null && safety < 1000) {
matches.push(m);
// Advance if zero-length match to prevent infinite loop
if (m[0].length === 0) {
regex.lastIndex++;
}
safety++;
}
} else {
const m = regex.exec(text);
if (m) matches.push(m);
}
state.matches = matches;
renderHighlights(matches);
renderMatches(matches);
renderBadge(matches.length, false);
}
// ── Highlight overlay ─────────────────────────────────────────────────────────
const HL_CLASSES = ['hl-0', 'hl-1', 'hl-2', 'hl-3', 'hl-4'];
function renderHighlights(matches) {
if (!matches.length || !state.text) {
highlightOverlay.innerHTML = escHtml(state.text);
return;
}
const text = state.text;
let html = '';
let cursor = 0;
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index;
const end = start + m[0].length;
// Text before match
if (start > cursor) {
html += escHtml(text.slice(cursor, start));
}
// Highlighted match
const cls = HL_CLASSES[i % HL_CLASSES.length];
html += '<mark class="' + cls + '">' + escHtml(m[0] || '\u200b') + '</mark>';
cursor = end;
// Advance past zero-length matches to avoid overlapping
if (m[0].length === 0) cursor++;
}
// Remaining text
if (cursor < text.length) {
html += escHtml(text.slice(cursor));
}
highlightOverlay.innerHTML = html;
syncScroll();
}
// ── Match list ────────────────────────────────────────────────────────────────
function renderMatches(matches) {
if (!state.pattern) {
matchList.innerHTML = '<div class="match-empty">Введите паттерн и текст для поиска</div>';
return;
}
if (!matches.length) {
matchList.innerHTML = '<div class="match-empty">Совпадений не найдено</div>';
return;
}
const frag = document.createDocumentFragment();
matches.forEach((m, idx) => {
const item = document.createElement('div');
item.className = 'match-item';
const num = document.createElement('div');
num.className = 'match-num';
num.textContent = '#' + (idx + 1) + ' · индекс ' + m.index;
item.appendChild(num);
const txt = document.createElement('div');
txt.style.marginBottom = '4px';
const badge = document.createElement('span');
badge.className = 'match-text';
badge.textContent = m[0] !== '' ? m[0] : '(пустое совпадение)';
txt.appendChild(badge);
item.appendChild(txt);
// Groups
const groups = m.slice(1).filter((_, i) => i < 20);
if (groups.length) {
const groupsWrap = document.createElement('div');
groupsWrap.className = 'match-groups';
groups.forEach((g, gi) => {
const tag = document.createElement('span');
tag.className = 'group-tag';
tag.innerHTML = 'Группа ' + (gi + 1) + ': <span class="group-val">'
+ (g !== undefined ? escHtml(String(g)) : '<em>undefined</em>') + '</span>';
groupsWrap.appendChild(tag);
});
item.appendChild(groupsWrap);
}
frag.appendChild(item);
});
matchList.innerHTML = '';
matchList.appendChild(frag);
}
// ── Badge ─────────────────────────────────────────────────────────────────────
function renderBadge(count, isError) {
if (isError) {
matchBadge.textContent = 'ошибка';
matchBadge.className = 'match-badge error-badge';
return;
}
if (count === null || !state.pattern) {
matchBadge.textContent = 'нет совпадений';
matchBadge.className = 'match-badge no-match';
return;
}
if (count === 0) {
matchBadge.textContent = 'нет совпадений';
matchBadge.className = 'match-badge no-match';
return;
}
matchBadge.textContent = count + ' ' + pluralMatch(count);
matchBadge.className = 'match-badge';
}
function pluralMatch(n) {
const m = n % 10, m100 = n % 100;
if (m === 1 && m100 !== 11) return 'совпадение';
if (m >= 2 && m <= 4 && (m100 < 12 || m100 > 14)) return 'совпадения';
return 'совпадений';
}
// ── Utility ───────────────────────────────────────────────────────────────────
function escHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/ /g, '\u00a0'); // preserve spaces for overlay
}
// ── Quick reference ───────────────────────────────────────────────────────────
function toggleQuickRef() {
const panel = document.getElementById('quickRefPanel');
const toggle = document.getElementById('quickRefToggle');
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'block';
toggle.classList.toggle('open', !isOpen);
}
function insertToken(token) {
const decoded = token
.replace(/\\\\d/g, '\\d')
.replace(/\\\\D/g, '\\D')
.replace(/\\\\w/g, '\\w')
.replace(/\\\\W/g, '\\W')
.replace(/\\\\s/g, '\\s')
.replace(/\\\\S/g, '\\S')
.replace(/\\\\b/g, '\\b')
.replace(/\\\\n/g, '\\n');
const input = patternInput;
const start = input.selectionStart;
const end = input.selectionEnd;
const val = input.value;
input.value = val.slice(0, start) + decoded + val.slice(end);
input.selectionStart = input.selectionEnd = start + decoded.length;
input.focus();
state.pattern = input.value;
scheduleUpdate();
}
// ── Sync textarea/overlay height ──────────────────────────────────────────────
// Keep overlay same height as textarea when resized
const resizeObserver = new ResizeObserver(() => {
highlightOverlay.style.height = testInput.offsetHeight + 'px';
highlightOverlay.style.width = testInput.offsetWidth + 'px';
});
resizeObserver.observe(testInput);
// Initial render
runRegex();
// ── AI Explain ────────────────────────────────────────────────────────────────
async function aiExplain() {
const patternInput = document.getElementById('patternInput');
const pattern = patternInput ? patternInput.value.trim() : '';
if (!pattern) return;
const flags = Array.from(document.querySelectorAll('.flag-btn.active')).map(b => b.dataset.flag).join('');
const btn = document.getElementById('aiExplainBtn');
const resultDiv = document.getElementById('aiResult');
btn.disabled = true;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" style="animation:spin .7s linear infinite"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg> Думаю...';
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '';
let fullText = '';
try {
const res = await fetch('/api/ai/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern, flags }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка ' + res.status);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) throw new Error(parsed.error);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
let md = fullText.replace(/^```[a-z]*\n?/gm, '').replace(/```\s*$/gm, '').trim();
resultDiv.innerHTML = marked.parse(md);
}
} catch (e) {
if (e.message && !e.message.includes('JSON')) throw e;
}
}
}
if (!fullText.trim()) {
resultDiv.innerHTML = '<span style="color:var(--text-muted)">Пустой ответ от AI</span>';
}
} catch (err) {
resultDiv.innerHTML = '<span style="color:#f44336">' + (err.message || 'AI недоступен') + '</span>';
} finally {
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg> AI объяснение';
}
}
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>

View File

@ -4,16 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Регистрация — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
fontFamily: { mono: ['JetBrains Mono', 'monospace'], sans: ['Manrope', 'sans-serif'] },
colors: { surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' }, accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' } }
}}
};
</script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTML Sanitizer — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'sanitizer';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

View File

@ -23,8 +23,8 @@ if (typeof tailwind !== 'undefined') {
/* Categories */
const WA_CATEGORIES = [
{ id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/>', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video'] },
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>', tools: ['formatter', 'sanitizer', 'converter'] },
{ id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/>', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video', 'favicon'] },
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>', tools: ['formatter', 'sanitizer', 'converter', 'regex'] },
{ id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/>', tools: ['parser', 'httpclient', 'redirects'] },
{ id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>', tools: ['password', 'md', 'pdf'] },
];
@ -38,10 +38,12 @@ const WA_TOOLS = [
{ id: 'svgeditor', path: '/svgeditor', title: 'SVG', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/>' },
{ id: 'editor', path: '/editor', title: 'Фото', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/>' },
{ id: 'video', path: '/video', title: 'Видео', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/>' },
{ id: 'favicon', path: '/favicon', title: 'Favicon', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6z"/>'},
// Код
{ id: 'formatter', path: '/formatter', title: 'Formatter', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>' },
{ id: 'sanitizer', path: '/sanitizer', title: 'Sanitizer', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>' },
{ id: 'converter', path: '/converter', title: 'Converter', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/>' },
{ id: 'regex', path: '/regex', title: 'Regex', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/>'},
// Веб
{ id: 'parser', path: '/parser', title: 'Parser', category: 'web', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L6.257 6.514a4.5 4.5 0 001.242 7.244"/>' },
{ id: 'httpclient', path: '/httpclient', title: 'HTTP', category: 'web', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/>' },

View File

@ -4,16 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Status — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
fontFamily: { mono: ['JetBrains Mono', 'monospace'], sans: ['Manrope', 'sans-serif'] },
colors: { surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' }, accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' } }
}}
};
</script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Editor — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'svgeditor';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">

1
public/vendor/tailwind.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — Видео конвертер</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'video';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
@ -970,6 +970,7 @@
function startPolling() {
clearPollTimer();
subscribeWS(state.jobId);
state.pollTimer = setInterval(async () => {
try {
const res = await fetch('/video/progress/' + state.jobId);
@ -995,7 +996,7 @@
} catch {
// Network hiccup — keep polling
}
}, 1000);
}, wsConnected ? 3000 : 1000);
}
function clearPollTimer() {
@ -1005,6 +1006,48 @@
}
}
// ─── WebSocket progress (upgrade from polling) ─────────────────────────
let wsConn = null;
let wsConnected = false;
function connectWS() {
try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
wsConn = new WebSocket(proto + '//' + location.host + '/ws');
wsConn.onopen = () => { wsConnected = true; };
wsConn.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.jobId !== state.jobId) return;
if (data.type === 'progress') {
const pct = Math.min(99, Math.max(0, data.progress || 0));
setProgressUI(pct, 'Обработка... ' + pct + '%');
} else if (data.type === 'done') {
clearPollTimer();
onConversionDone(data);
} else if (data.type === 'error') {
clearPollTimer();
showConversionError(data.error || 'Ошибка обработки');
}
} catch {}
};
wsConn.onclose = () => { wsConnected = false; };
wsConn.onerror = () => { wsConnected = false; };
} catch { wsConnected = false; }
}
function subscribeWS(jobId) {
if (wsConn && wsConn.readyState === 1) {
wsConn.send(JSON.stringify({ type: 'subscribe', jobId }));
}
}
// Connect WS on load
connectWS();
function setProgressUI(percent, statusText) {
progressFill.style.width = percent + '%';
progressPercent.textContent = percent + '%';

View File

@ -46,4 +46,141 @@ router.get('/content/:section', async (req, res) => {
}
});
// AI explain regex (proxies to local llama.cpp)
const rateLimit = require('express-rate-limit');
const aiLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip,
windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' },
});
router.post('/ai/explain', express.json(), aiLimiter, async (req, res) => {
const { pattern, flags } = req.body;
if (!pattern || typeof pattern !== 'string' || pattern.length > 5000) {
return res.status(400).json({ error: 'Паттерн пустой или слишком длинный' });
}
let messages;
if (flags === 'security-audit') {
// Security audit mode — pattern contains full HTTP response info
messages = [
{ role: 'system', content: 'Ты эксперт по веб-безопасности. Отвечай на русском, markdown. Будь КРАТОК — только реальные проблемы, не перечисляй то что настроено правильно. Максимум 5 пунктов.' },
{ role: 'user', content: `Проведи security-аудит этого HTTP-ответа:
${pattern}
Ответь КРАТКО по структуре:
## Оценка: [КРИТИЧНО / СРЕДНЕ / ХОРОШО]
## Проблемы (только реальные, макс 5):
- Проблема как исправить (одной строкой)
## Что ОК (одной строкой, перечислением):
- Какие заголовки безопасности настроены правильно
НЕ разбирай каждый заголовок отдельно. НЕ повторяйся. Только практичные проблемы.` },
];
} else {
// Regex explain mode
messages = [
{ role: 'system', content: 'Ты эксперт по регулярным выражениям. Отвечай на русском. Формат ответа: markdown без обёртки в блок кода.' },
{ role: 'user', content: `Regex: /${pattern}/${flags || 'g'}
1. Одним предложением: что это выражение ДЕЛАЕТ? Какую задачу решает?
2. Примеры строк которые СОВПАДУТ с этим выражением (2-3 примера)
3. Примеры строк которые НЕ совпадут (2-3 примера)
4. Разбор по частям (кратко, таблицей): часть паттерна что значит` },
];
}
// SSE streaming
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120000);
// AI endpoints: PC (powerful) → RPi (fallback)
const AI_ENDPOINTS = [
{ url: 'http://192.168.31.100:11434/v1/chat/completions', model: 'qwen2.5:14b', name: 'PC' },
{ url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'qwen2.5-3b', name: 'RPi' },
];
let response = null;
let usedEndpoint = null;
for (const ep of AI_ENDPOINTS) {
try {
const pingCtrl = new AbortController();
const pingTimeout = setTimeout(() => pingCtrl.abort(), 3000);
const ping = await fetch(ep.url.replace('/v1/chat/completions', '/v1/models'), { signal: pingCtrl.signal });
clearTimeout(pingTimeout);
if (!ping.ok) continue;
response = await fetch(ep.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model: ep.model,
messages,
max_tokens: 1000,
temperature: 0.3,
stream: true,
}),
});
usedEndpoint = ep;
break;
} catch (pingErr) {
continue; // try next endpoint
}
}
if (!response) {
res.write('data: {"error":"Все AI серверы недоступны"}\n\n');
res.end();
return;
}
clearTimeout(timeout);
if (!response.ok) {
let errMsg = 'LLM API error: ' + response.status;
try { const errBody = await response.text(); console.error('LLM error:', response.status, errBody.slice(0, 300)); } catch {}
res.write('data: {"error":"' + errMsg + '"}\n\n');
res.end();
return;
}
// Pipe SSE chunks from llama.cpp to client using async iteration
try {
for await (const chunk of response.body) {
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString();
res.write(text);
}
} catch (streamErr) {
res.write('data: {"error":"Stream error"}\n\n');
}
res.write('data: [DONE]\n\n');
res.end();
req.on('close', () => {
controller.abort();
});
} catch (err) {
if (err.name === 'AbortError') {
res.write('data: {"error":"AI не ответил за 2 минуты"}\n\n');
} else {
res.write('data: {"error":"AI недоступен"}\n\n');
}
res.end();
}
});
module.exports = router;

View File

@ -10,6 +10,7 @@ const SALT_ROUNDS = 10;
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
windowMs: 15 * 60 * 1000, // 15 min
max: 10, // 10 attempts per window
message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' },

View File

@ -7,15 +7,11 @@ const archiver = require('archiver');
const rateLimit = require('express-rate-limit');
const geoip = require('geoip-lite');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const router = express.Router();
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
for (const dir of [UPLOADS_DIR, DOWNLOADS_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// Cleanup old files
function cleanupDir(dir, maxAgeMs) {
@ -33,7 +29,7 @@ function cleanupDir(dir, maxAgeMs) {
}
setInterval(() => cleanupDir(UPLOADS_DIR, 10 * 60 * 1000), 2 * 60 * 1000);
setInterval(() => cleanupDir(DOWNLOADS_DIR, 30 * 60 * 1000), 5 * 60 * 1000);
setInterval(() => cleanupDir(RESULTS_DIR, 30 * 60 * 1000), 5 * 60 * 1000);
// Multer config
const maxFileSize = (parseInt(process.env.MAX_FILE_SIZE_MB) || 20) * 1024 * 1024;
@ -51,6 +47,7 @@ const upload = multer({
// Rate limiter
const limiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
standardHeaders: true,
@ -121,7 +118,7 @@ router.post('/', limiter, upload.array('images', maxFiles), async (req, res) =>
}
const archiveName = `archive_${Date.now()}.zip`;
const archivePath = path.join(DOWNLOADS_DIR, archiveName);
const archivePath = path.join(RESULTS_DIR, archiveName);
const output = fs.createWriteStream(archivePath);
const archive = archiver('zip', { zlib: { level: 9 } });
const stats = [];
@ -197,6 +194,101 @@ router.post('/', limiter, upload.array('images', maxFiles), async (req, res) =>
}
});
// Single file compress endpoint
router.post('/single', limiter, upload.single('image'), async (req, res) => {
const resize = parseInt(req.body.resize || '0', 10);
const format = (req.body.format || 'original').toLowerCase();
const quality = Math.min(100, Math.max(1, parseInt(req.body.quality || process.env.COMPRESS_QUALITY || '80', 10)));
const validFormats = ['original', 'webp', 'jpeg', 'png', 'avif', 'tiff', 'gif'];
if (!validFormats.includes(format)) {
return res.status(400).json({ error: 'Неверный формат' });
}
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
const country = getCountry(req.ip);
const inputPath = req.file.path;
const originalSize = req.file.size;
try {
const image = sharp(inputPath);
const metadata = await image.metadata();
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
const scale = resize / Math.max(metadata.width, metadata.height);
const newWidth = Math.round(metadata.width * scale);
const newHeight = Math.round(metadata.height * scale);
image.resize({ width: newWidth, height: newHeight });
const msg = "[" + country + "] " + req.ip + " single resized: " + req.file.originalname + " " + metadata.width + "x" + metadata.height + " -> " + newWidth + "x" + newHeight + " (" + (originalSize / 1024).toFixed(0) + "KB)";
log.info(msg); log.logToFile(msg);
} else {
const msg = "[" + country + "] " + req.ip + " single compress: " + req.file.originalname + " " + metadata.width + "x" + metadata.height + " format=" + format + " q=" + quality + " (" + (originalSize / 1024).toFixed(0) + "KB)";
log.info(msg); log.logToFile(msg);
}
let buffer;
let outputMimetype;
if (format === 'webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
else if (format === 'jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
else if (format === 'png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
else if (format === 'avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
else if (format === 'tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
else if (format === 'gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
else {
if (req.file.mimetype === 'image/jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
else if (req.file.mimetype === 'image/png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
else if (req.file.mimetype === 'image/webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
else if (req.file.mimetype === 'image/avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
else if (req.file.mimetype === 'image/tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
else if (req.file.mimetype === 'image/gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
else { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
}
const readableName = fixMulterFilename(req.file.originalname);
const newExt = getOutputExtension(format, readableName);
const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
const outputFilename = 'single_' + Date.now() + '_' + transliterate(rawName);
const outputPath = require('path').join(RESULTS_DIR, outputFilename);
require('fs').writeFileSync(outputPath, buffer);
const compressedSize = buffer.length;
const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0;
setTimeout(() => {
try { require('fs').existsSync(outputPath) && require('fs').unlinkSync(outputPath); } catch(e) {}
}, 30 * 60 * 1000);
res.json({
filename: readableName,
outputFilename,
originalSize,
compressedSize,
savings,
downloadUrl: '/compress/download/' + outputFilename,
mimetype: outputMimetype,
});
} catch (err) {
log.error('Single compression error', { error: err.message });
res.status(500).json({ error: 'Ошибка при сжатии' });
} finally {
try { require('fs').existsSync(inputPath) && require('fs').unlinkSync(inputPath); } catch(e) {}
}
});
// Individual file download
router.get('/download/:filename', (req, res) => {
const filename = require('path').basename(req.params.filename);
const filePath = require('path').join(RESULTS_DIR, filename);
if (!require('fs').existsSync(filePath)) {
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
}
res.download(filePath, filename);
});
// Multer error handler
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
@ -209,4 +301,4 @@ router.use((err, req, res, next) => {
});
module.exports = router;
module.exports.DOWNLOADS_DIR = DOWNLOADS_DIR;
module.exports.RESULTS_DIR = RESULTS_DIR;

173
routes/favicon.js Normal file
View File

@ -0,0 +1,173 @@
const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const archiver = require('archiver');
const path = require('path');
const fs = require('fs');
const rateLimit = require('express-rate-limit');
const { RESULTS_DIR } = require('../lib/storage');
const log = require('../lib/logger');
const router = express.Router();
// ── Rate limiter ──────────────────────────────────────────────────────────────
const limiter = rateLimit({
keyGenerator: (req) =>
req.session && req.session.user && req.session.user.id
? 'user_' + req.session.user.id
: req.ip,
windowMs: 60000,
max: 30,
message: { error: 'Слишком много запросов' },
});
// ── Multer (memory storage, 5 MB, images only) ────────────────────────────────
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Допустимы только изображения'));
}
},
});
// ── Favicon sizes spec ────────────────────────────────────────────────────────
const FAVICON_SIZES = [
{ size: 16, name: 'favicon-16x16.png', purpose: 'ico' },
{ size: 32, name: 'favicon-32x32.png', purpose: 'ico' },
{ size: 48, name: 'favicon-48x48.png', purpose: 'ico' },
{ size: 180, name: 'apple-touch-icon.png', purpose: 'apple' },
{ size: 192, name: 'android-chrome-192x192.png', purpose: 'android' },
{ size: 512, name: 'android-chrome-512x512.png', purpose: 'android' },
];
// ── Cleanup helper ────────────────────────────────────────────────────────────
function scheduleCleanup(dirPath, delayMs = 30 * 60 * 1000) {
setTimeout(() => {
fs.rm(dirPath, { recursive: true, force: true }, (err) => {
if (err) log.warn('Favicon cleanup failed', { dir: dirPath, error: err.message });
else log.debug('Favicon temp dir removed', { dir: dirPath });
});
}, delayMs);
}
// ── GET / — serve page ────────────────────────────────────────────────────────
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'favicon.html'));
});
// ── POST /generate ────────────────────────────────────────────────────────────
router.post('/generate', limiter, upload.single('image'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Изображение не загружено' });
}
const timestamp = Date.now();
const tempDirName = `favicon_${timestamp}`;
const tempDir = path.join(RESULTS_DIR, tempDirName);
try {
fs.mkdirSync(tempDir, { recursive: true });
// Generate all PNG sizes
const generatedSizes = [];
for (const spec of FAVICON_SIZES) {
await sharp(req.file.buffer)
.resize(spec.size, spec.size, { fit: 'cover', position: 'centre' })
.png({ compressionLevel: 9 })
.toFile(path.join(tempDir, spec.name));
generatedSizes.push({ size: spec.size, filename: spec.name });
}
// Build site.webmanifest
const manifestJson = {
name: '',
short_name: '',
icons: [
{ src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
};
const manifestStr = JSON.stringify(manifestJson, null, 2);
fs.writeFileSync(path.join(tempDir, 'site.webmanifest'), manifestStr);
// Build HTML tags snippet
const htmlTags = [
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">',
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">',
'<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">',
'<link rel="manifest" href="/site.webmanifest">',
].join('\n');
fs.writeFileSync(path.join(tempDir, 'favicon-tags.html'), htmlTags);
// Create ZIP archive
const zipName = `favicon_${timestamp}.zip`;
const zipPath = path.join(RESULTS_DIR, zipName);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
archive.directory(tempDir, false);
archive.finalize();
});
// Schedule cleanup of temp dir and zip
scheduleCleanup(tempDir);
scheduleCleanup(zipPath);
log.info('Favicon generated', { timestamp, sizes: generatedSizes.length });
res.json({
downloadUrl: `/favicon/download/${zipName}`,
sizes: generatedSizes,
htmlTags,
manifestJson: manifestStr,
});
} catch (err) {
log.error('Favicon generation failed', { error: err.message });
fs.rm(tempDir, { recursive: true, force: true }, () => {});
res.status(500).json({ error: 'Ошибка генерации favicon: ' + err.message });
}
});
// ── GET /download/:filename ───────────────────────────────────────────────────
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
// Allow only favicon zip files to prevent path traversal
if (!/^favicon_\d+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Недопустимое имя файла' });
}
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
}
res.download(filePath, 'favicon-pack.zip');
});
// ── Multer error handler ──────────────────────────────────────────────────────
router.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'Файл слишком большой. Максимум 5 МБ.' });
}
if (err.message === 'Допустимы только изображения') {
return res.status(415).json({ error: err.message });
}
next(err);
});
module.exports = router;

View File

@ -24,6 +24,7 @@ router.delete('/api/history', (req, res) => {
// Proxy
const proxyLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
windowMs: 60 * 1000,
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },

View File

@ -18,5 +18,6 @@ router.get('/sanitizer', (req, res) => res.sendFile(pub('sanitizer.html')));
router.get('/converter', (req, res) => res.sendFile(pub('converter.html')));
router.get('/formatter', (req, res) => res.sendFile(pub('formatter.html')));
router.get('/password', (req, res) => res.sendFile(pub('password.html')));
router.get('/regex', (req, res) => res.sendFile(pub('regex.html')));
module.exports = router;

View File

@ -11,7 +11,8 @@ const rateLimit = require('express-rate-limit');
const router = express.Router();
// Rate limit parser endpoints (prevent DDoS via server)
const parserLimiter = rateLimit({ windowMs: 60000, max: 20, message: { error: 'Слишком много запросов' } });
const parserLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 20, message: { error: 'Слишком много запросов' } });
// In-memory cache (max 50 entries, 10 min TTL)
const cache = new Map();

View File

@ -6,11 +6,10 @@ const { spawn } = require('child_process');
const rateLimit = require('express-rate-limit');
const archiver = require('archiver');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const router = express.Router();
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const upload = multer({
@ -23,7 +22,8 @@ const upload = multer({
},
});
const limiter = rateLimit({ windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } });
const limiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } });
// Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic)
function fixFilename(str) {
@ -63,7 +63,7 @@ function getFile(id) {
function saveResult(buffer, ext) {
const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`;
const outPath = path.join(DOWNLOADS_DIR, name);
const outPath = path.join(RESULTS_DIR, name);
fs.writeFileSync(outPath, buffer);
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
return `/pdf/download/${name}`;
@ -110,7 +110,7 @@ async function renderPreview(req, res, pageNum) {
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const cacheKey = `${f.id}_p${pageNum}`;
const cachePath = path.join(DOWNLOADS_DIR, `preview_${cacheKey}.png`);
const cachePath = path.join(RESULTS_DIR, `preview_${cacheKey}.png`);
// Return cached if exists
if (fs.existsSync(cachePath)) {
@ -405,7 +405,7 @@ router.post('/compress', express.json(), async (req, res) => {
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const outName = `compressed_${Date.now()}.pdf`;
const outPath = path.join(DOWNLOADS_DIR, outName);
const outPath = path.join(RESULTS_DIR, outName);
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
try {
@ -478,7 +478,7 @@ router.post('/toImages', express.json(), async (req, res) => {
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const tmpDir = path.join(DOWNLOADS_DIR, `img_${Date.now()}`);
const tmpDir = path.join(RESULTS_DIR, `img_${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const device = format === 'jpg' ? 'jpeg' : 'png16m';
@ -496,7 +496,7 @@ router.post('/toImages', express.json(), async (req, res) => {
// ZIP the images
const zipName = `pages_${Date.now()}.zip`;
const zipPath = path.join(DOWNLOADS_DIR, zipName);
const zipPath = path.join(RESULTS_DIR, zipName);
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 6 } });
archive.pipe(output);
@ -549,7 +549,7 @@ router.post('/fromImages', upload.array('images', 50), async (req, res) => {
// Download
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
const filePath = path.join(DOWNLOADS_DIR, filename);
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath);
});

View File

@ -11,7 +11,8 @@ const UA_STRINGS = {
googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
};
const redirectLimiter = rateLimit({ windowMs: 60_000, max: 20 });
const redirectLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, windowMs: 60_000, max: 20 });
router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => {
const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {};

View File

@ -5,13 +5,14 @@ const fs = require('fs');
const { spawn } = require('child_process');
const rateLimit = require('express-rate-limit');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const queue = require('../lib/queue');
const ws = require('../lib/ws');
const router = express.Router();
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB for users
const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024; // 1GB for admin
const MAX_FILE_SIZE = 200 * 1024 * 1024;
const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024;
function videoFileFilter(req, file, cb) {
const valid = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/mpeg', 'video/3gpp', 'video/ogg'];
@ -25,34 +26,37 @@ function videoFileFilter(req, file, cb) {
const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: videoFileFilter });
const uploadAdmin = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE_ADMIN }, fileFilter: videoFileFilter });
// Pick multer based on user role
function uploadMiddleware(req, res, next) {
const isAdmin = req.session && req.session.user && req.session.user.role === 'admin';
const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video');
handler(req, res, next);
}
const limiter = rateLimit({ windowMs: 60000, max: 10, message: { error: 'Слишком много запросов' } });
const limiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
windowMs: 60000, max: 10, message: { error: 'Слишком много запросов' },
});
// Active jobs tracking
const jobs = new Map();
// In-memory progress tracking for active FFmpeg processes
const liveProgress = new Map();
function runFFmpeg(args, jobId) {
function runFFmpeg(args, jobId, duration) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { timeout: 600000 }); // 10 min timeout
const proc = spawn('ffmpeg', args, { timeout: 600000 });
let stderrBuf = '';
proc.stderr.on('data', (d) => {
const chunk = d.toString();
stderrBuf += chunk;
// Parse progress — look for time= or out_time= in chunk
const timeMatch = chunk.match(/(?:out_time|time)=\s*(\d{2}):(\d{2}):(\d{2})[\.\d]*/);
if (timeMatch && jobs.has(jobId)) {
if (timeMatch && duration > 0) {
const secs = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
const job = jobs.get(jobId);
if (job.duration > 0) {
const pct = Math.min(99, Math.round((secs / job.duration) * 100));
if (pct > job.progress) job.progress = pct; // only increase
const pct = Math.min(99, Math.round((secs / duration) * 100));
const live = liveProgress.get(jobId);
if (live && pct > live.progress) {
live.progress = pct;
queue.updateProgress(jobId, pct);
ws.notify(jobId, { type: "progress", progress: pct });
}
}
});
@ -66,7 +70,6 @@ function runFFmpeg(args, jobId) {
});
}
// Get video duration
function getVideoDuration(filePath) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
@ -105,27 +108,19 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
try {
const info = await getVideoDuration(req.file.path);
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
jobs.set(jobId, {
// Create job in SQLite queue
const jobId = queue.addJob('video', {
inputPath: req.file.path,
originalName: req.file.originalname,
size: req.file.size,
duration: info.duration,
progress: 0,
status: 'ready',
outputPath: null,
info,
});
// Cleanup job after 30 min
// Schedule cleanup of input file after 30 min
setTimeout(() => {
const job = jobs.get(jobId);
if (job) {
try { fs.existsSync(job.inputPath) && fs.unlinkSync(job.inputPath); } catch {}
try { job.outputPath && fs.existsSync(job.outputPath) && fs.unlinkSync(job.outputPath); } catch {}
jobs.delete(jobId);
}
try { fs.existsSync(req.file.path) && fs.unlinkSync(req.file.path); } catch {}
}, 30 * 60 * 1000);
log.info(`Video upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)}MB, ${info.duration.toFixed(1)}s)`);
@ -140,92 +135,103 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
// Convert
router.post('/convert', express.json(), async (req, res) => {
const { jobId, mode, format, quality, startTime, endTime } = req.body;
const job = jobs.get(jobId);
const job = queue.getJob(jobId);
if (!job) return res.status(404).json({ error: 'Задача не найдена' });
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
job.status = 'processing';
job.progress = 0;
queue.startJob(jobId);
const payload = job.payload;
const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' };
const outExt = ext[format] || '.mp4';
const outFile = path.join(DOWNLOADS_DIR, `${jobId}${outExt}`);
job.outputPath = outFile;
const outFile = path.join(RESULTS_DIR, `${jobId}${outExt}`);
// Track live progress in memory
liveProgress.set(jobId, { progress: 0 });
try {
let args = ['-y', '-progress', 'pipe:2', '-i', job.inputPath];
let args = ['-y', '-progress', 'pipe:2', '-i', payload.inputPath];
// Time range (for GIF or trim)
if (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime));
if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime));
switch (mode) {
case 'convert': {
// Format conversion (software encoders — mpeg4 for mp4/avi/mkv)
if (format === 'mp4') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-f', 'avi'); // webm fallback to avi
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
else if (format === 'mkv') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
if (format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'libvpx', '-b:v', '1M', '-c:a', 'libvorbis');
else if (format === 'avi') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
else if (format === 'mkv') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
break;
}
case 'compress': {
// Quality via mpeg4 q:v (1=best, 31=worst) + format selection
const qv = quality === 'high' ? '3' : quality === 'low' ? '15' : '8';
args.push('-c:v', 'mpeg4', '-q:v', qv, '-c:a', 'aac', '-b:a', '128k');
const crf = quality === 'high' ? '18' : quality === 'low' ? '28' : '23';
args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', crf, '-c:a', 'aac', '-b:a', '128k');
if (format === 'mp4' || !format) args.push('-movflags', '+faststart');
break;
}
case 'audio': {
args.push('-vn');
if (format === 'mp3') args.push('-c:a', 'aac', '-b:a', '192k');
if (format === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k');
else args.push('-c:a', 'aac', '-b:a', '192k');
break;
}
case 'gif': {
// GIF from video
const w = Math.min(job.info.width || 480, 480);
const w = Math.min(payload.info.width || 480, 480);
args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0');
break;
}
default:
job.status = 'error';
queue.failJob(jobId, 'Неизвестный режим');
return res.status(400).json({ error: 'Неизвестный режим' });
}
args.push(outFile);
// Start ffmpeg in background — respond immediately
// Respond immediately, process in background
res.json({ status: 'processing' });
runFFmpeg(args, jobId).then(() => {
job.status = 'done';
job.progress = 100;
runFFmpeg(args, jobId, payload.duration).then(() => {
const outStat = fs.statSync(outFile);
job.outputSize = outStat.size;
job.savings = job.size > 0 ? Math.round((1 - outStat.size / job.size) * 100) : 0;
job.downloadUrl = `/video/download/${jobId}${outExt}`;
log.info(`Video ${mode}: ${job.originalName}${format} (${(outStat.size / 1024 / 1024).toFixed(1)}MB, ${job.savings}% saved)`);
const outputSize = outStat.size;
const savings = payload.size > 0 ? Math.round((1 - outputSize / payload.size) * 100) : 0;
const downloadUrl = `/video/download/${jobId}${outExt}`;
queue.finishJob(jobId, { downloadUrl, outputSize, savings });
ws.notify(jobId, { type: "done", downloadUrl, outputSize, savings });
liveProgress.delete(jobId);
// Cleanup output after 30 min
setTimeout(() => { try { fs.existsSync(outFile) && fs.unlinkSync(outFile); } catch {} }, 30 * 60 * 1000);
log.info(`Video ${mode}: ${payload.originalName}${format} (${(outputSize / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`);
}).catch(err => {
job.status = 'error';
job.error = err.message.slice(0, 200);
queue.failJob(jobId, err.message.slice(0, 200));
ws.notify(jobId, { type: 'error', error: err.message.slice(0, 200) });
liveProgress.delete(jobId);
log.error('Video convert error', { error: err.message });
});
} catch (err) {
job.status = 'error';
queue.failJob(jobId, err.message);
liveProgress.delete(jobId);
log.error('Video convert error', { error: err.message });
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
}
});
// Progress (returns downloadUrl when done)
// Progress
router.get('/progress/:jobId', (req, res) => {
const job = jobs.get(req.params.jobId);
const job = queue.getJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Not found' });
const result = { status: job.status, progress: job.progress };
if (job.status === 'done') {
result.downloadUrl = job.downloadUrl;
result.outputSize = job.outputSize;
result.savings = job.savings;
// Use live progress if available (more up-to-date)
const live = liveProgress.get(req.params.jobId);
const progress = live ? live.progress : job.progress;
const result = { status: job.status, progress };
if (job.status === 'done' && job.result) {
result.downloadUrl = job.result.downloadUrl;
result.outputSize = job.result.outputSize;
result.savings = job.result.savings;
}
if (job.status === 'error') {
result.error = job.error || 'Ошибка обработки';
@ -236,7 +242,7 @@ router.get('/progress/:jobId', (req, res) => {
// Download result
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
const filePath = path.join(DOWNLOADS_DIR, filename);
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath);
});

View File

@ -1,7 +1,7 @@
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const sessionMiddleware = require('./lib/session');
const log = require('./lib/logger');
const authMiddleware = require('./lib/auth');
@ -11,22 +11,26 @@ app.set('trust proxy', 1);
// Security headers
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: false, // Tailwind CDN needs inline scripts
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://mc.yandex.ru"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://mc.yandex.ru"],
connectSrc: ["'self'", "wss:", "ws:"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
scriptSrcAttr: ["'unsafe-inline'"],
},
},
crossOriginEmbedderPolicy: false,
}));
// Session
app.use(session({
secret: process.env.SESSION_SECRET || 'change-me-in-env',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 24 * 60 * 60 * 1000, // 1 day
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
},
}));
app.use(sessionMiddleware);
// Static assets (before auth — always public)
app.use(express.static('public'));
@ -51,15 +55,35 @@ app.use(authMiddleware);
app.use('/api', apiRouter);
// Health check (extended)
const os = require('os');
const startTime = Date.now();
app.get('/health', (req, res) => {
const mem = process.memoryUsage();
const queue = require('./lib/queue');
const wsInfo = require('./lib/ws');
const loadavg = os.loadavg();
res.json({
status: 'ok',
uptime: Math.floor((Date.now() - startTime) / 1000),
memory: {
rss: Math.round(mem.rss / 1024 / 1024) + 'MB',
heap: Math.round(mem.heapUsed / 1024 / 1024) + '/' + Math.round(mem.heapTotal / 1024 / 1024) + 'MB',
system: Math.round((1 - os.freemem() / os.totalmem()) * 100) + '%',
},
cpu: {
load1m: loadavg[0].toFixed(2),
load5m: loadavg[1].toFixed(2),
load15m: loadavg[2].toFixed(2),
cores: os.cpus().length,
},
queue: {
stats: queue.getStats(),
active: queue.getActiveCount(),
pending: queue.getQueueDepth(),
},
ws: {
connections: wsInfo.getConnectionCount(),
},
node: process.version,
pid: process.pid,
@ -75,7 +99,7 @@ const fs = require('fs');
const path = require('path');
app.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
const filePath = path.join(compressRouter.DOWNLOADS_DIR, filename);
const filePath = path.join(require('./lib/storage').RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath, 'compressed.zip');
});
@ -88,6 +112,7 @@ app.use(require('./routes/redirects'));
app.use(require('./routes/svgeditor'));
app.use('/video', require('./routes/video'));
app.use('/pdf', require('./routes/pdf'));
app.use('/favicon', require('./routes/favicon'));
app.use(require('./routes/pages'));
// Global error handler
@ -113,6 +138,10 @@ const PORT = parseInt(process.env.PORT) || 3000;
log.info(`Server started on http://localhost:${PORT}`);
});
// WebSocket
const wsServer = require('./lib/ws');
wsServer.attach(server);
// Graceful shutdown
function shutdown(signal) {
log.info(`${signal} received, shutting down gracefully...`);

28
tailwind.config.js Normal file
View File

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./public/**/*.html', './public/**/*.js'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
sans: ['Manrope', 'sans-serif'],
},
colors: {
surface: {
900: '#060c18',
800: '#091020',
700: '#0d1828',
600: '#162040',
},
accent: {
DEFAULT: '#0054e6',
dim: '#0043b8',
bright: '#6b9fff',
},
danger: '#ef4444',
},
},
},
plugins: [],
};