Sync prod working changes: new tools (regex/favicon), queue/session/storage/ws libs, video/compress/pdf/redirects updates
This commit is contained in:
parent
306101649f
commit
87f8c55da0
111
DOCS.md
111
DOCS.md
@ -35,13 +35,17 @@
|
|||||||
| Среда выполнения | Node.js 20 |
|
| Среда выполнения | Node.js 20 |
|
||||||
| Веб-фреймворк | Express 5 |
|
| Веб-фреймворк | Express 5 |
|
||||||
| База данных | MariaDB 10.11 |
|
| База данных | MariaDB 10.11 |
|
||||||
|
| Хранение сессий | express-mysql-session (MariaDB) |
|
||||||
|
| Очередь задач | better-sqlite3 (SQLite WAL, `/mnt/webdata/storage/jobs.db`) |
|
||||||
| Обработка изображений | Sharp 0.34 |
|
| Обработка изображений | Sharp 0.34 |
|
||||||
| Обработка видео | FFmpeg (системный), ffprobe |
|
| Обработка видео | FFmpeg 5.1.8 (libx264, libvpx, libmp3lame), ffprobe |
|
||||||
| Обработка PDF | pdf-lib, pdf-parse, Ghostscript (системный) |
|
| Обработка PDF | pdf-lib, pdf-parse, Ghostscript (системный) |
|
||||||
| Парсинг HTML | @mozilla/readability, jsdom |
|
| Парсинг HTML | @mozilla/readability, jsdom |
|
||||||
| Авторизация | express-session, bcrypt |
|
| Авторизация | express-session, bcrypt |
|
||||||
| Панель администратора | AdminJS 7 + @adminjs/sequelize |
|
| Панель администратора | AdminJS 7 + @adminjs/sequelize |
|
||||||
| Стили | Tailwind CSS (CDN), кастомный shared.css |
|
| Стили | Tailwind CSS 3 (CLI build, `vendor/tailwind.min.css`) |
|
||||||
|
| WebSocket | ws (реалтайм прогресс конвертации) |
|
||||||
|
| Логирование | pino (структурированный JSON) |
|
||||||
| Процесс-менеджер | PM2 |
|
| Процесс-менеджер | PM2 |
|
||||||
|
|
||||||
### Сервер
|
### Сервер
|
||||||
@ -69,7 +73,11 @@
|
|||||||
│ ├── auth.js # Middleware авторизации: список публичных путей, редиректы
|
│ ├── auth.js # Middleware авторизации: список публичных путей, редиректы
|
||||||
│ ├── admin.js # Настройка AdminJS: Sequelize модели, ресурсы, монтирование
|
│ ├── admin.js # Настройка AdminJS: Sequelize модели, ресурсы, монтирование
|
||||||
│ ├── db.js # Пул соединений MariaDB (mysql2/promise), хелперы для users
|
│ ├── 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-резолвинг
|
│ └── ssrf.js # Защита от SSRF: проверка IP-диапазонов, DNS-резолвинг
|
||||||
│
|
│
|
||||||
├── routes/
|
├── routes/
|
||||||
@ -650,6 +658,10 @@ session({
|
|||||||
|
|
||||||
### Rate limiting для auth
|
### Rate limiting для auth
|
||||||
|
|
||||||
|
Rate limit работает per-user: `keyGenerator` использует `req.session.user.id` для авторизованных пользователей, `req.ip` для анонимных. Это предотвращает ситуацию, когда один бот исчерпывает лимит для всех.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
15 минут, 10 попыток (применяется к `POST /auth/login` и `POST /auth/register`).
|
15 минут, 10 попыток (применяется к `POST /auth/login` и `POST /auth/register`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -961,12 +973,25 @@ session({
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
helmet({
|
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,
|
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`)
|
### Защита от SSRF (`lib/ssrf.js`)
|
||||||
|
|
||||||
@ -999,11 +1024,14 @@ helmet({
|
|||||||
|
|
||||||
### Сессия
|
### Сессия
|
||||||
|
|
||||||
|
- Хранение: MariaDB через `express-mysql-session` (таблица `sessions`)
|
||||||
|
- Сессии переживают рестарт PM2 — пользователи остаются залогиненными
|
||||||
- `httpOnly: true` — недоступно JavaScript
|
- `httpOnly: true` — недоступно JavaScript
|
||||||
- `secure: true` в production — только по HTTPS
|
- `secure: true` в production — только по HTTPS
|
||||||
- `sameSite: 'lax'` — защита от CSRF
|
- `sameSite: 'lax'` — защита от CSRF
|
||||||
- Session regeneration при логине — защита от session fixation attack
|
- Session regeneration при логине — защита от session fixation attack
|
||||||
- Logout: `req.session.destroy()`
|
- Logout: `req.session.destroy()`
|
||||||
|
- Автоочистка: `checkExpirationInterval: 900000` (15 мин), `expiration: 86400000` (24ч)
|
||||||
|
|
||||||
### Санитизация ввода
|
### Санитизация ввода
|
||||||
|
|
||||||
@ -1136,25 +1164,40 @@ Let's Encrypt через certbot. Сертификат:
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"uptime": 86400,
|
"uptime": 86400,
|
||||||
"memory": {
|
"memory": {
|
||||||
"rss": "124MB",
|
"rss": "302MB",
|
||||||
"heap": "67/128MB"
|
"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
|
"pid": 12345
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Uptime считается от момента запуска процесса Node.js (не PM2).
|
Расширенный health: системная память, CPU load, глубина очереди задач, активные WebSocket подключения.
|
||||||
|
|
||||||
### Логирование
|
### Логирование
|
||||||
|
|
||||||
**stdout/stderr** (`lib/logger.js`):
|
**pino** (`lib/logger.js`):
|
||||||
- Формат: `[ISO timestamp] [LEVEL] message {meta JSON}`
|
- Формат: структурированный JSON (`{"level":30,"time":...,"msg":"..."}`)
|
||||||
- Уровни: `error`, `warn`, `info`, `debug`
|
- Уровни: `error`, `warn`, `info`, `debug` (контролируется через `LOG_LEVEL`)
|
||||||
- Уровень контролируется через `LOG_LEVEL` в `.env`
|
- API совместим: `log.info(msg, meta)`, `log.error(msg, meta)`, `log.logToFile(text)`
|
||||||
- PM2 перенаправляет в файлы логов
|
- PM2 перенаправляет stdout/stderr в файлы логов
|
||||||
|
|
||||||
**compress.log** — отдельный файл для операций сжатия изображений и парсинга:
|
**logToFile** — append-запись в файл для операций сжатия/парсинга:
|
||||||
- Формат: `[ISO timestamp] [RU] 1.2.3.4 compress: file.jpg 800x600 format=webp q=80 (234KB)`
|
- Формат: `[ISO timestamp] [RU] 1.2.3.4 compress: file.jpg 800x600 format=webp q=80 (234KB)`
|
||||||
- Включает страну из GeoIP, IP, имя файла, параметры, размер
|
- Включает страну из GeoIP, IP, имя файла, параметры, размер
|
||||||
|
|
||||||
@ -1162,32 +1205,25 @@ Uptime считается от момента запуска процесса No
|
|||||||
|
|
||||||
## 12. Известные ограничения
|
## 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: медленная загрузка
|
### AdminJS: медленная загрузка
|
||||||
|
|
||||||
ESM-импорт AdminJS при старте занимает ~6 секунд. В течение этого времени `/admin` возвращает 404 (маршрут ещё не зарегистрирован). После рестарта PM2 нужно подождать перед открытием панели.
|
ESM-импорт AdminJS при старте занимает ~6 секунд. В течение этого времени `/admin` возвращает 404. После рестарта PM2 нужно подождать перед открытием панели.
|
||||||
|
|
||||||
### In-memory сессии
|
|
||||||
|
|
||||||
Сессии хранятся в памяти Node.js процесса. При `pm2 restart` все пользователи разлогиниваются. Аналогично теряются: история HTTP-клиента, задачи видеоконвертера (`jobs Map`), загруженные PDF-файлы (`pdfFiles Map`).
|
|
||||||
|
|
||||||
### Память (swap)
|
### Память (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` тоже присутствует — реализация не завершена.
|
Добавить вход через Google и Яндекс. Пакет `openid-client` уже установлен в зависимостях, `passport` тоже присутствует — реализация не завершена.
|
||||||
|
|
||||||
### Persistent sessions
|
|
||||||
|
|
||||||
Перенести хранение сессий из памяти в MariaDB (пакет `express-mysql-session` или аналог). Позволит пользователям оставаться залогиненными после перезапуска сервера.
|
|
||||||
|
|
||||||
### Улучшения инструментов
|
### Улучшения инструментов
|
||||||
|
|
||||||
| Инструмент | Улучшение |
|
| Инструмент | Улучшение |
|
||||||
@ -1208,16 +1240,15 @@ Sharp работает нативно и быстро. FFmpeg и Ghostscript —
|
|||||||
| Конвертер изображений | Quality slider в UI (сейчас фиксируется в .env) |
|
| Конвертер изображений | Quality slider в UI (сейчас фиксируется в .env) |
|
||||||
| Генератор паролей | Режим passphrase (несколько слов через дефис) |
|
| Генератор паролей | Режим passphrase (несколько слов через дефис) |
|
||||||
| HTTP-клиент | Поддержка переменных окружения (как в Postman) |
|
| HTTP-клиент | Поддержка переменных окружения (как в Postman) |
|
||||||
| Видео конвертер | Установить libx264 и переключить encoder |
|
|
||||||
|
|
||||||
### Usage analytics
|
### 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 лимиты.
|
||||||
|
|||||||
115
EDITOR-DOC.md
115
EDITOR-DOC.md
@ -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 |
|
|
||||||
@ -343,7 +343,7 @@ GET /placeholder/800x600/0054e6/ffffff/Hello
|
|||||||
|
|
||||||
### HTTP-заголовки
|
### 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)
|
### Rate Limiting (express-rate-limit)
|
||||||
|
|
||||||
@ -577,7 +577,7 @@ certbot --nginx -d images.wadevelop.ru
|
|||||||
| Безопасность | Helmet | HTTP security headers |
|
| Безопасность | Helmet | HTTP security headers |
|
||||||
| Rate Limiting | express-rate-limit | Ограничение частоты запросов |
|
| Rate Limiting | express-rate-limit | Ограничение частоты запросов |
|
||||||
| Шрифты SVG | opentype.js | Работа с шрифтами (вспомогательная) |
|
| Шрифты SVG | opentype.js | Работа с шрифтами (вспомогательная) |
|
||||||
| UI | Tailwind CSS (CDN) | Стилизация интерфейса |
|
| UI | Tailwind CSS 3 (CLI build) | Стилизация интерфейса |
|
||||||
| Шрифты UI | JetBrains Mono, Manrope | Типографика |
|
| Шрифты UI | JetBrains Mono, Manrope | Типографика |
|
||||||
| Процесс | PM2 | Управление процессом в production |
|
| Процесс | PM2 | Управление процессом в production |
|
||||||
| Веб-сервер | Nginx | Reverse proxy, SSL termination |
|
| Веб-сервер | Nginx | Reverse proxy, SSL termination |
|
||||||
|
|||||||
@ -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
3
input.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@ -1,31 +1,27 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
const LOG_FILE = path.join(__dirname, '..', 'compress.log');
|
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() {
|
const logger = pino({
|
||||||
return new Date().toISOString();
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
}
|
transport: process.env.NODE_ENV !== 'production' ? {
|
||||||
|
target: 'pino/file',
|
||||||
function log(level, msg, meta) {
|
options: { destination: 1 },
|
||||||
if ((LEVEL_PRIORITY[level] ?? 2) > currentLevel) return;
|
} : undefined,
|
||||||
const ts = formatTimestamp();
|
});
|
||||||
const metaStr = meta ? ' ' + JSON.stringify(meta) : '';
|
|
||||||
const line = `[${ts}] [${level.toUpperCase()}] ${msg}${metaStr}`;
|
|
||||||
process.stdout.write(line + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function logToFile(text) {
|
function logToFile(text) {
|
||||||
const ts = formatTimestamp();
|
const ts = new Date().toISOString();
|
||||||
fs.appendFile(LOG_FILE, `[${ts}] ${text}\n`, () => {});
|
fs.appendFile(LOG_FILE, `[${ts}] ${text}\n`, () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
info: (msg, meta) => log('info', msg, meta),
|
info: (msg, meta) => meta ? logger.info(meta, msg) : logger.info(msg),
|
||||||
warn: (msg, meta) => log('warn', msg, meta),
|
warn: (msg, meta) => meta ? logger.warn(meta, msg) : logger.warn(msg),
|
||||||
error: (msg, meta) => log('error', msg, meta),
|
error: (msg, meta) => meta ? logger.error(meta, msg) : logger.error(msg),
|
||||||
debug: (msg, meta) => log('debug', msg, meta),
|
debug: (msg, meta) => meta ? logger.debug(meta, msg) : logger.debug(msg),
|
||||||
logToFile,
|
logToFile,
|
||||||
|
logger,
|
||||||
};
|
};
|
||||||
|
|||||||
136
lib/queue.js
Normal file
136
lib/queue.js
Normal 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
29
lib/session.js
Normal 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
13
lib/storage.js
Normal 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
57
lib/ws.js
Normal 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
1396
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -4,7 +4,9 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -19,9 +21,11 @@
|
|||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-formidable": "^1.2.0",
|
"express-formidable": "^1.2.0",
|
||||||
|
"express-mysql-session": "^3.0.3",
|
||||||
"express-rate-limit": "^8.3.0",
|
"express-rate-limit": "^8.3.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"geoip-lite": "^1.4.10",
|
"geoip-lite": "^1.4.10",
|
||||||
@ -37,7 +41,12 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-merger-js": "^5.1.2",
|
"pdf-merger-js": "^5.1.2",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"pino": "^10.3.1",
|
||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
"sharp": "^0.34.1"
|
"sharp": "^0.34.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
|
||||||
@ -4,28 +4,105 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Конвертер изображений — WA Dev Tools</title>
|
<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>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<script>window.WA_TOOL_ID = 'compress';</script>
|
<script>window.WA_TOOL_ID = 'compress';</script>
|
||||||
<style>
|
<style>
|
||||||
.dropzone-active { border-color: #0054e6 !important; background: rgba(0, 84, 230, 0.05) !important; }
|
/* ── Dropzone ── */
|
||||||
.dropzone-active .drop-icon { transform: scale(1.1) translateY(-4px); }
|
.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; }
|
||||||
.drop-icon { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
.dropzone-wrap:hover, .dropzone-wrap.active { border-color: var(--accent); background: var(--accent-bg); }
|
||||||
.savings-bar { height: 6px; border-radius: 3px; background: var(--bar-bg); overflow: hidden; }
|
.dropzone-wrap .drop-icon { transition: transform .3s cubic-bezier(.34,1.56,.64,1); }
|
||||||
.savings-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
|
.dropzone-wrap.active .drop-icon { transform: scale(1.12) translateY(-4px); }
|
||||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
|
/* ── Settings bar ── */
|
||||||
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
|
.settings-row { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
@keyframes pulse-border { 0%, 100% { border-color: rgba(0, 84, 230, 0.2); } 50% { border-color: rgba(0, 84, 230, 0.5); } }
|
.settings-row > * { flex: 1 1 120px; min-width: 0; }
|
||||||
.processing { animation: pulse-border 1.5s ease-in-out infinite; }
|
.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; }
|
||||||
.preview-grid img { transition: transform 0.2s ease; }
|
.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; }
|
||||||
.preview-grid img:hover { transform: scale(1.05); }
|
.ctrl-select:focus { border-color: var(--accent); }
|
||||||
progress { appearance: none; height: 4px; border-radius: 2px; overflow: hidden; }
|
.ctrl-select option { background: var(--select-bg); color: var(--select-color); }
|
||||||
progress::-webkit-progress-bar { background: var(--bar-bg); border-radius: 2px; }
|
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
|
||||||
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); }
|
/* ── 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
<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">
|
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
|
||||||
Конвертер <span class="text-accent">изображений</span>
|
Конвертер <span class="text-accent">изображений</span>
|
||||||
</h1>
|
</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>
|
||||||
</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">
|
<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 -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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">
|
<p style="font-size:14px;color:var(--text-muted)">
|
||||||
Перетащите файлы сюда или <span class="text-accent font-medium">выберите</span>
|
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Preview -->
|
<!-- Settings row -->
|
||||||
<div id="preview" class="preview-grid grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4 hidden"></div>
|
<div class="settings-row">
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
<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>
|
<div class="ctrl-label">Формат</div>
|
||||||
<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">
|
<select id="format" class="ctrl-select">
|
||||||
<option value="original">Оригинал</option>
|
<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="webp">WebP</option>
|
||||||
<option value="jpeg">JPEG</option>
|
<option value="jpeg">JPEG</option>
|
||||||
<option value="png">PNG</option>
|
<option value="png">PNG</option>
|
||||||
|
<option value="avif">AVIF</option>
|
||||||
|
<option value="tiff">TIFF</option>
|
||||||
|
<option value="gif">GIF</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<div class="ctrl-label">Размер</div>
|
||||||
<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">
|
<select id="resize" class="ctrl-select">
|
||||||
<option value="0">Не изменять</option>
|
<option value="0">Не изменять</option>
|
||||||
<option value="4600">4600px</option>
|
<option value="4600">4600px</option>
|
||||||
<option value="3600">3600px</option>
|
<option value="3600">3600px</option>
|
||||||
@ -103,269 +168,382 @@
|
|||||||
<option value="custom">Свой размер...</option>
|
<option value="custom">Свой размер...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div class="text-sm font-semibold dark:text-white text-gray-900" id="summaryTitle">Готово</div>
|
<div class="ctrl-label">Качество</div>
|
||||||
<div class="text-xs dark:text-gray-500 text-gray-400 font-mono" id="summaryDetail"></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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Stats table -->
|
<!-- Error block -->
|
||||||
<div class="overflow-x-auto">
|
<div id="errorBlock" class="error-block hidden"></div>
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
<!-- Summary bar -->
|
||||||
<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">
|
<div id="summaryBar" class="summary-bar hidden">
|
||||||
<th class="text-left px-5 py-3">Файл</th>
|
<div>
|
||||||
<th class="text-right px-3 py-3">Было</th>
|
<div style="font-size:13px;font-weight:700;color:var(--text-primary)" id="summaryTitle">0 файлов</div>
|
||||||
<th class="text-right px-3 py-3">Стало</th>
|
<div class="summary-stat" id="summaryDetail"></div>
|
||||||
<th class="text-right px-5 py-3 w-32">Экономия</th>
|
</div>
|
||||||
</tr>
|
<div style="text-align:right">
|
||||||
</thead>
|
<div class="summary-savings" id="summarySavings" style="color:var(--accent)">—</div>
|
||||||
<tbody id="statsBody"></tbody>
|
<div style="font-size:11px;color:var(--text-muted)">общая экономия</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download button -->
|
<!-- File list -->
|
||||||
<a id="downloadBtn" href="#" download
|
<div id="fileList" class="file-list hidden"></div>
|
||||||
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
|
<!-- Download all -->
|
||||||
hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
|
<button id="dlAllBtn" class="dl-all-btn hidden" type="button">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
<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" />
|
<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>
|
</svg>
|
||||||
Скачать архив
|
Скачать всё архивом
|
||||||
</a>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
</div><!-- /main-content -->
|
</div><!-- /main-content -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = id => document.getElementById(id);
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const dropzone = $('dropzone');
|
function fmtSize(bytes) {
|
||||||
const fileInput = $('fileInput');
|
|
||||||
const progressBar = $('progressBar');
|
|
||||||
const compressBtn = $('compressBtn');
|
|
||||||
const results = $('results');
|
|
||||||
const errorBlock = $('errorBlock');
|
|
||||||
const preview = $('preview');
|
|
||||||
|
|
||||||
let selectedFiles = null;
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes < 1024) return bytes + ' Б';
|
if (bytes < 1024) return bytes + ' Б';
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
|
||||||
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
|
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluralFiles(n) {
|
function fmtBadge(mimetype, outputName) {
|
||||||
const mod = n % 10;
|
const ext = (outputName || '').split('.').pop().toLowerCase();
|
||||||
const mod100 = n % 100;
|
const typeMap = {
|
||||||
if (mod === 1 && mod100 !== 11) return n + ' файл';
|
'image/jpeg': 'jpeg', 'image/jpg': 'jpeg',
|
||||||
if (mod >= 2 && mod <= 4 && (mod100 < 12 || mod100 > 14)) return n + ' файла';
|
'image/png': 'png',
|
||||||
return n + ' файлов';
|
'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('click', () => fileInput.click());
|
||||||
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dropzone-active'); });
|
dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
|
||||||
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dropzone-active'));
|
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('active'); });
|
||||||
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dropzone-active'); processFiles(e.dataTransfer.files); });
|
dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('active'); });
|
||||||
fileInput.addEventListener('change', e => processFiles(e.target.files));
|
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) {
|
// ── File handling ─────────────────────────────────────────────────────────────
|
||||||
results.classList.add('hidden');
|
// Handle custom resize: prompt immediately on select change
|
||||||
errorBlock.classList.add('hidden');
|
document.getElementById('resize').addEventListener('change', function() {
|
||||||
preview.innerHTML = '';
|
if (this.value === 'custom') {
|
||||||
preview.classList.add('hidden');
|
const val = prompt('Размер по длинной стороне (px):', '1024');
|
||||||
compressBtn.disabled = true;
|
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'];
|
function handleFiles(files) {
|
||||||
const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024);
|
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) {
|
if (!valid.length) {
|
||||||
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
|
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (invalid > 0) {
|
||||||
|
showError(invalid + ' файл(ов) пропущено (неверный формат или размер > 20 МБ)');
|
||||||
|
}
|
||||||
|
|
||||||
selectedFiles = valid;
|
valid.forEach(f => addFileEntry(f));
|
||||||
$('fileCount').classList.remove('hidden');
|
drainQueue();
|
||||||
$('fileCountText').textContent = pluralFiles(valid.length);
|
}
|
||||||
|
|
||||||
// Show previews (max 8)
|
function addFileEntry(file) {
|
||||||
const shown = valid.slice(0, 8);
|
const id = ++entryCounter;
|
||||||
shown.forEach(file => {
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = e => {
|
reader.onload = e => {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = e.target.result;
|
img.src = e.target.result;
|
||||||
img.className = 'w-full aspect-square object-cover rounded-lg border dark:border-surface-600 border-gray-200';
|
img.className = 'file-thumb';
|
||||||
preview.appendChild(img);
|
img.alt = '';
|
||||||
preview.classList.remove('hidden');
|
const ph = document.getElementById('thumb-' + id);
|
||||||
|
if (ph) ph.replaceWith(img);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
|
||||||
if (valid.length > 8) {
|
// Info
|
||||||
const more = document.createElement('div');
|
const info = 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';
|
info.className = 'file-info';
|
||||||
more.textContent = '+' + (valid.length - 8);
|
info.innerHTML = '<div class="file-name" title="' + escHtml(file.name) + '">' + escHtml(truncName(file.name, 38)) + '</div>'
|
||||||
preview.appendChild(more);
|
+ '<div class="file-meta">'
|
||||||
preview.classList.remove('hidden');
|
+ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
compressBtn.disabled = false;
|
function escHtml(str) {
|
||||||
compressBtn.textContent = 'Сжать';
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return name.slice(0, maxLen - 3) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg) {
|
// ── Upload queue ──────────────────────────────────────────────────────────────
|
||||||
errorBlock.textContent = msg;
|
function drainQueue() {
|
||||||
errorBlock.classList.remove('hidden');
|
while (activeUploads < MAX_CONCURRENT && pendingQueue.length > 0) {
|
||||||
|
const entry = pendingQueue.shift();
|
||||||
|
uploadEntry(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compressBtn.addEventListener('click', () => {
|
function uploadEntry(entry) {
|
||||||
if (!selectedFiles || !selectedFiles.length) return;
|
activeUploads++;
|
||||||
|
entry.status = 'uploading';
|
||||||
compressBtn.disabled = true;
|
|
||||||
compressBtn.textContent = 'Сжимаю...';
|
|
||||||
results.classList.add('hidden');
|
|
||||||
errorBlock.classList.add('hidden');
|
|
||||||
progressBar.hidden = false;
|
|
||||||
progressBar.value = 0;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
selectedFiles.forEach(f => formData.append('images', f));
|
formData.append('image', entry.file);
|
||||||
formData.append('format', $('format').value);
|
formData.append('format', document.getElementById('format').value);
|
||||||
formData.append('quality', $('quality').value);
|
formData.append('quality', document.getElementById('quality').value);
|
||||||
var rv = $('resize').value;
|
let rv = document.getElementById('resize').value;
|
||||||
if (rv === 'custom') rv = prompt('Размер по длинной стороне (px):', '1024') || '0';
|
if (rv === 'custom') rv = '0';
|
||||||
formData.append('resize', rv);
|
formData.append('resize', rv);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
fetch('/compress/single', { method: 'POST', body: formData })
|
||||||
xhr.open('POST', '/compress');
|
.then(r => {
|
||||||
xhr.responseType = 'json';
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
xhr.upload.onprogress = e => {
|
function renderRowResult(entry, data) {
|
||||||
if (e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100;
|
const row = entry.rowEl;
|
||||||
};
|
row.classList.remove('loading');
|
||||||
|
|
||||||
xhr.onload = () => {
|
const resultEl = document.getElementById('result-' + entry.id);
|
||||||
progressBar.hidden = true;
|
if (!resultEl) return;
|
||||||
compressBtn.textContent = 'Сжать';
|
|
||||||
compressBtn.disabled = false;
|
|
||||||
|
|
||||||
if (xhr.status === 200 && xhr.response && xhr.response.success) {
|
const pct = data.savings;
|
||||||
renderResults(xhr.response);
|
const cls = savingsClass(pct);
|
||||||
} else if (xhr.status === 429) {
|
const sign = pct > 0 ? '−' : (pct < 0 ? '+' : '');
|
||||||
showError('Слишком много запросов. Подождите минуту.');
|
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 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 {
|
} else {
|
||||||
const msg = xhr.response && xhr.response.error ? xhr.response.error : 'Ошибка при сжатии.';
|
titleEl.textContent = pluralFiles(total);
|
||||||
showError(msg);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
if (done.length > 0) {
|
||||||
progressBar.hidden = true;
|
const totalOrig = done.reduce((s, e) => s + e.result.originalSize, 0);
|
||||||
compressBtn.textContent = 'Сжать';
|
const totalComp = done.reduce((s, e) => s + e.result.compressedSize, 0);
|
||||||
compressBtn.disabled = false;
|
const pct = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
|
||||||
showError('Ошибка сети. Повторите позже.');
|
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 = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
xhr.send(formData);
|
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>Скачать всё архивом';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 barColor(pct) {
|
|
||||||
if (pct >= 40) return 'bg-accent';
|
|
||||||
if (pct >= 15) return 'bg-accent/60';
|
|
||||||
if (pct >= 0) return 'bg-warn';
|
|
||||||
return 'bg-danger';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function(m,e,t,r,i,k,a){
|
(function(m,e,t,r,i,k,a){
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Format Converter — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'converter';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,69 +4,47 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WA Dev Tools — Панель</title>
|
<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>window.WA_TOOL_ID = 'home';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<style>
|
<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-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 { 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-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-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-user { font-size:13px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; }
|
||||||
.dash-logout { color:var(--text-muted); transition:color .15s; }
|
.dash-logout { color:var(--text-muted); transition:color .15s; }
|
||||||
.dash-logout:hover { color:var(--danger, #ff5252); }
|
.dash-logout:hover { color:#ff5252; }
|
||||||
@media(max-width:768px) {
|
@media(max-width:768px) { .dash-header { left:0; } .dash-logo svg { display:none; } }
|
||||||
.dash-header { left:0; }
|
|
||||||
.dash-nav { display:none; }
|
|
||||||
.dash-logo svg { display:none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
@keyframes fadeUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||||
.fade-up { animation: fadeUp .4s ease-out forwards; }
|
.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 { 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); }
|
.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; }
|
.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 { background:rgba(0,84,230,.18); }
|
.tool-tile:hover .tile-icon { filter:brightness(1.2); }
|
||||||
.tile-title { font-size:14px; font-weight:600; color:var(--text-primary); }
|
.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; }
|
.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 { 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 { 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-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-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; }
|
.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; } }
|
@media(max-width:640px) { .cat-desc { display:none; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
|
|
||||||
<!-- Top header -->
|
|
||||||
<header class="dash-header">
|
<header class="dash-header">
|
||||||
<a href="/dashboard" class="dash-logo">
|
<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>
|
<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>
|
WA Dev <span>Tools</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="dash-nav">
|
|
||||||
</nav>
|
|
||||||
<div class="dash-right">
|
<div class="dash-right">
|
||||||
<span class="dash-user" id="headerUser"></span>
|
<span class="dash-user" id="headerUser"></span>
|
||||||
<a href="/auth/logout" class="dash-logout" title="Выйти">
|
<a href="/auth/logout" class="dash-logout" title="Выйти">
|
||||||
@ -78,142 +56,120 @@
|
|||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<div class="tool-container relative z-10 max-w-5xl mx-auto" style="padding-top:72px;">
|
<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;">
|
<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 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;">
|
||||||
</h1>
|
Инструменты для веб-разработки: сжатие картинок и видео, форматирование кода, тестирование API и многое другое.
|
||||||
<p style="font-size:14px;color:var(--text-secondary);margin-top:8px;line-height:1.6;max-width:600px;">
|
|
||||||
14 инструментов для веб-разработки: сжатие картинок и видео,
|
|
||||||
форматирование кода, тестирование API, генерация паролей и многое другое.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category: Images -->
|
<div id="toolsGrid"></div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// 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 => {
|
fetch('/auth/me').then(r => r.ok ? r.json() : null).then(user => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const name = user.name || user.email.split('@')[0];
|
const name = user.name || user.email.split('@')[0];
|
||||||
const el = document.getElementById('greeting');
|
document.getElementById('greeting').textContent = 'Привет, ' + name;
|
||||||
if (el) el.textContent = 'Привет, ' + name;
|
document.getElementById('headerUser').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;
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
</script>
|
</script>
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Code Editor — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'editor';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
325
public/favicon.html
Normal file
325
public/favicon.html
Normal 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>
|
||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Code Formatter — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'formatter';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HTTP Client — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'httpclient';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
@ -211,7 +211,22 @@
|
|||||||
@media(max-width: 900px) {
|
@media(max-width: 900px) {
|
||||||
.panels-grid { grid-template-columns: 1fr; }
|
.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>
|
</style>
|
||||||
|
<script src="/vendor/marked.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
||||||
|
|
||||||
@ -343,6 +358,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Response Headers -->
|
<!-- 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="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 id="responseHeadersContent" class="text-xs font-mono space-y-1"></div>
|
||||||
</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);
|
addToHistory(req.method, req.url, data.status, data.time);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusBar.innerHTML = `<span class="text-xs font-mono" style="color:#ff5252">Ошибка соединения: ${escHtml(err.message)}</span>`;
|
statusBar.innerHTML = `<span class="text-xs font-mono" style="color:#ff5252">Ошибка соединения: ${escHtml(err.message)}</span>`;
|
||||||
@ -700,6 +738,83 @@
|
|||||||
|
|
||||||
renderHistory();
|
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>
|
</script>
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|||||||
@ -12,25 +12,9 @@
|
|||||||
|
|
||||||
<meta name="description" content="Сжатие картинок, форматирование кода, HTTP-клиент и ещё 10 инструментов для веб-разработчиков — всё в браузере, " />
|
<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">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|
||||||
|
|||||||
@ -4,16 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Вход — WA Dev Tools</title>
|
<title>Вход — WA Dev Tools</title>
|
||||||
<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">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Markdown Viewer — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'md';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
@ -231,19 +231,27 @@ document.getElementById('copyHtmlBtn').addEventListener('click', () => {
|
|||||||
// Download HTML
|
// Download HTML
|
||||||
document.getElementById('downloadHtmlBtn').addEventListener('click', () => {
|
document.getElementById('downloadHtmlBtn').addEventListener('click', () => {
|
||||||
const body = document.getElementById('mdOutput').innerHTML;
|
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 -->
|
const fullHtml = `<!DOCTYPE html>
|
||||||
<script type="text/javascript">
|
<html>
|
||||||
(function(m,e,t,r,i,k,a){
|
<head>
|
||||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
<meta charset="UTF-8">
|
||||||
m[i].l=1*new Date();
|
<title>Markdown</title>
|
||||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
<style>
|
||||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#333}
|
||||||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
|
pre{background:#f5f5f5;padding:1em;border-radius:6px;overflow-x:auto}
|
||||||
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
code{font-family:monospace;font-size:.9em;background:#f0f0f0;padding:2px 5px;border-radius:3px}
|
||||||
</script>
|
pre code{background:none;padding:0}
|
||||||
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
table{border-collapse:collapse;width:100%}
|
||||||
<!-- /Yandex.Metrika counter -->
|
th,td{border:1px solid #ddd;padding:8px;text-align:left}
|
||||||
</body>\n</html>';
|
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 blob = new Blob([fullHtml], { type: 'text/html' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Парсер статей — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'parser';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Генератор паролей — WA Dev Tools</title>
|
<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>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WA Dev Tools — PDF инструменты</title>
|
<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>window.WA_TOOL_ID = 'pdf';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Placeholder — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'placeholder';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Redirect Analyzer — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'redirects';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
@ -233,7 +233,19 @@
|
|||||||
}
|
}
|
||||||
.url-text a { color: #6b9fff; text-decoration: none; }
|
.url-text a { color: #6b9fff; text-decoration: none; }
|
||||||
.url-text a:hover { text-decoration: underline; }
|
.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>
|
</style>
|
||||||
|
<script src="/vendor/marked.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
||||||
|
|
||||||
@ -326,6 +338,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Headers detail -->
|
<!-- 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">
|
<details class="details-accordion" id="headersAccordion">
|
||||||
<summary>
|
<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>
|
<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);
|
renderIssues(data);
|
||||||
renderChain(data);
|
renderChain(data);
|
||||||
renderHeaders(data);
|
renderHeaders(data);
|
||||||
|
|
||||||
|
// Store for AI audit
|
||||||
|
window._lastRedirectData = data;
|
||||||
|
document.getElementById('aiAuditWrap').classList.remove('hidden');
|
||||||
|
document.getElementById('aiAuditResult').classList.add('hidden');
|
||||||
|
|
||||||
showState('result');
|
showState('result');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -599,6 +626,84 @@
|
|||||||
if (qp.get('url')) { urlInput.value = qp.get('url'); analyze(); }
|
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>
|
</script>
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|||||||
610
public/regex.html
Normal file
610
public/regex.html
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.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>
|
||||||
@ -4,16 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Регистрация — WA Dev Tools</title>
|
<title>Регистрация — WA Dev Tools</title>
|
||||||
<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">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HTML Sanitizer — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'sanitizer';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
@ -23,8 +23,8 @@ if (typeof tailwind !== 'undefined') {
|
|||||||
|
|
||||||
/* Categories */
|
/* Categories */
|
||||||
const WA_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: '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'] },
|
{ 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: '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'] },
|
{ 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: '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: '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: '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: '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: '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: '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: '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"/>' },
|
{ 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"/>' },
|
||||||
|
|||||||
@ -4,16 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Status — WA Dev Tools</title>
|
<title>Status — WA Dev Tools</title>
|
||||||
<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">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SVG Editor — WA Dev Tools</title>
|
<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>window.WA_TOOL_ID = 'svgeditor';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
|
|||||||
1
public/vendor/tailwind.min.css
vendored
Normal file
1
public/vendor/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WA Dev Tools — Видео конвертер</title>
|
<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>window.WA_TOOL_ID = 'video';</script>
|
||||||
<script src="/shared.js?v=3"></script>
|
<script src="/shared.js?v=3"></script>
|
||||||
<link href="/vendor/fonts.css" rel="stylesheet">
|
<link href="/vendor/fonts.css" rel="stylesheet">
|
||||||
@ -970,6 +970,7 @@
|
|||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
clearPollTimer();
|
clearPollTimer();
|
||||||
|
subscribeWS(state.jobId);
|
||||||
state.pollTimer = setInterval(async () => {
|
state.pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/video/progress/' + state.jobId);
|
const res = await fetch('/video/progress/' + state.jobId);
|
||||||
@ -995,7 +996,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// Network hiccup — keep polling
|
// Network hiccup — keep polling
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, wsConnected ? 3000 : 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPollTimer() {
|
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) {
|
function setProgressUI(percent, statusText) {
|
||||||
progressFill.style.width = percent + '%';
|
progressFill.style.width = percent + '%';
|
||||||
progressPercent.textContent = percent + '%';
|
progressPercent.textContent = percent + '%';
|
||||||
|
|||||||
137
routes/api.js
137
routes/api.js
@ -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;
|
module.exports = router;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const SALT_ROUNDS = 10;
|
|||||||
|
|
||||||
// Rate limiting for auth endpoints
|
// Rate limiting for auth endpoints
|
||||||
const authLimiter = rateLimit({
|
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
|
windowMs: 15 * 60 * 1000, // 15 min
|
||||||
max: 10, // 10 attempts per window
|
max: 10, // 10 attempts per window
|
||||||
message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' },
|
message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' },
|
||||||
|
|||||||
@ -7,15 +7,11 @@ const archiver = require('archiver');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const geoip = require('geoip-lite');
|
const geoip = require('geoip-lite');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
|
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// Cleanup old files
|
||||||
function cleanupDir(dir, maxAgeMs) {
|
function cleanupDir(dir, maxAgeMs) {
|
||||||
@ -33,7 +29,7 @@ function cleanupDir(dir, maxAgeMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInterval(() => cleanupDir(UPLOADS_DIR, 10 * 60 * 1000), 2 * 60 * 1000);
|
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
|
// Multer config
|
||||||
const maxFileSize = (parseInt(process.env.MAX_FILE_SIZE_MB) || 20) * 1024 * 1024;
|
const maxFileSize = (parseInt(process.env.MAX_FILE_SIZE_MB) || 20) * 1024 * 1024;
|
||||||
@ -51,6 +47,7 @@ const upload = multer({
|
|||||||
|
|
||||||
// Rate limiter
|
// Rate limiter
|
||||||
const limiter = rateLimit({
|
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,
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
|
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
@ -121,7 +118,7 @@ router.post('/', limiter, upload.array('images', maxFiles), async (req, res) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const archiveName = `archive_${Date.now()}.zip`;
|
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 output = fs.createWriteStream(archivePath);
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
const stats = [];
|
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
|
// Multer error handler
|
||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
@ -209,4 +301,4 @@ router.use((err, req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.DOWNLOADS_DIR = DOWNLOADS_DIR;
|
module.exports.RESULTS_DIR = RESULTS_DIR;
|
||||||
|
|||||||
173
routes/favicon.js
Normal file
173
routes/favicon.js
Normal 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;
|
||||||
@ -24,6 +24,7 @@ router.delete('/api/history', (req, res) => {
|
|||||||
|
|
||||||
// Proxy
|
// Proxy
|
||||||
const proxyLimiter = rateLimit({
|
const proxyLimiter = rateLimit({
|
||||||
|
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
||||||
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
||||||
|
|||||||
@ -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('/converter', (req, res) => res.sendFile(pub('converter.html')));
|
||||||
router.get('/formatter', (req, res) => res.sendFile(pub('formatter.html')));
|
router.get('/formatter', (req, res) => res.sendFile(pub('formatter.html')));
|
||||||
router.get('/password', (req, res) => res.sendFile(pub('password.html')));
|
router.get('/password', (req, res) => res.sendFile(pub('password.html')));
|
||||||
|
router.get('/regex', (req, res) => res.sendFile(pub('regex.html')));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -11,7 +11,8 @@ const rateLimit = require('express-rate-limit');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Rate limit parser endpoints (prevent DDoS via server)
|
// 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)
|
// In-memory cache (max 50 entries, 10 min TTL)
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
|
|||||||
@ -6,11 +6,10 @@ const { spawn } = require('child_process');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
|
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
||||||
|
|
||||||
const router = express.Router();
|
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 MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
const upload = multer({
|
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)
|
// Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic)
|
||||||
function fixFilename(str) {
|
function fixFilename(str) {
|
||||||
@ -63,7 +63,7 @@ function getFile(id) {
|
|||||||
|
|
||||||
function saveResult(buffer, ext) {
|
function saveResult(buffer, ext) {
|
||||||
const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${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);
|
fs.writeFileSync(outPath, buffer);
|
||||||
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
||||||
return `/pdf/download/${name}`;
|
return `/pdf/download/${name}`;
|
||||||
@ -110,7 +110,7 @@ async function renderPreview(req, res, pageNum) {
|
|||||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
|
|
||||||
const cacheKey = `${f.id}_p${pageNum}`;
|
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
|
// Return cached if exists
|
||||||
if (fs.existsSync(cachePath)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
@ -405,7 +405,7 @@ router.post('/compress', express.json(), async (req, res) => {
|
|||||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
|
|
||||||
const outName = `compressed_${Date.now()}.pdf`;
|
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' };
|
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -478,7 +478,7 @@ router.post('/toImages', express.json(), async (req, res) => {
|
|||||||
const f = getFile(fileId);
|
const f = getFile(fileId);
|
||||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
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 });
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
||||||
|
|
||||||
@ -496,7 +496,7 @@ router.post('/toImages', express.json(), async (req, res) => {
|
|||||||
|
|
||||||
// ZIP the images
|
// ZIP the images
|
||||||
const zipName = `pages_${Date.now()}.zip`;
|
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 output = fs.createWriteStream(zipPath);
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
archive.pipe(output);
|
archive.pipe(output);
|
||||||
@ -549,7 +549,7 @@ router.post('/fromImages', upload.array('images', 50), async (req, res) => {
|
|||||||
// Download
|
// Download
|
||||||
router.get('/download/:filename', (req, res) => {
|
router.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath);
|
res.download(filePath);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,8 @@ const UA_STRINGS = {
|
|||||||
googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
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) => {
|
router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => {
|
||||||
const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {};
|
const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {};
|
||||||
|
|||||||
140
routes/video.js
140
routes/video.js
@ -5,13 +5,14 @@ const fs = require('fs');
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const log = require('../lib/logger');
|
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 router = express.Router();
|
||||||
|
|
||||||
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
|
const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||||
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
|
const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024;
|
||||||
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB for users
|
|
||||||
const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024; // 1GB for admin
|
|
||||||
|
|
||||||
function videoFileFilter(req, file, cb) {
|
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'];
|
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 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 });
|
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) {
|
function uploadMiddleware(req, res, next) {
|
||||||
const isAdmin = req.session && req.session.user && req.session.user.role === 'admin';
|
const isAdmin = req.session && req.session.user && req.session.user.role === 'admin';
|
||||||
const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video');
|
const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video');
|
||||||
handler(req, res, next);
|
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
|
// In-memory progress tracking for active FFmpeg processes
|
||||||
const jobs = new Map();
|
const liveProgress = new Map();
|
||||||
|
|
||||||
function runFFmpeg(args, jobId) {
|
function runFFmpeg(args, jobId, duration) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn('ffmpeg', args, { timeout: 600000 }); // 10 min timeout
|
const proc = spawn('ffmpeg', args, { timeout: 600000 });
|
||||||
let stderrBuf = '';
|
let stderrBuf = '';
|
||||||
|
|
||||||
proc.stderr.on('data', (d) => {
|
proc.stderr.on('data', (d) => {
|
||||||
const chunk = d.toString();
|
const chunk = d.toString();
|
||||||
stderrBuf += chunk;
|
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]*/);
|
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 secs = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
|
||||||
const job = jobs.get(jobId);
|
const pct = Math.min(99, Math.round((secs / duration) * 100));
|
||||||
if (job.duration > 0) {
|
const live = liveProgress.get(jobId);
|
||||||
const pct = Math.min(99, Math.round((secs / job.duration) * 100));
|
if (live && pct > live.progress) {
|
||||||
if (pct > job.progress) job.progress = pct; // only increase
|
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) {
|
function getVideoDuration(filePath) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
|
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 {
|
try {
|
||||||
const info = await getVideoDuration(req.file.path);
|
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,
|
inputPath: req.file.path,
|
||||||
originalName: req.file.originalname,
|
originalName: req.file.originalname,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
duration: info.duration,
|
duration: info.duration,
|
||||||
progress: 0,
|
|
||||||
status: 'ready',
|
|
||||||
outputPath: null,
|
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup job after 30 min
|
// Schedule cleanup of input file after 30 min
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const job = jobs.get(jobId);
|
try { fs.existsSync(req.file.path) && fs.unlinkSync(req.file.path); } catch {}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, 30 * 60 * 1000);
|
}, 30 * 60 * 1000);
|
||||||
|
|
||||||
log.info(`Video upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)}MB, ${info.duration.toFixed(1)}s)`);
|
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
|
// Convert
|
||||||
router.post('/convert', express.json(), async (req, res) => {
|
router.post('/convert', express.json(), async (req, res) => {
|
||||||
const { jobId, mode, format, quality, startTime, endTime } = req.body;
|
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) return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
|
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
|
||||||
|
|
||||||
job.status = 'processing';
|
queue.startJob(jobId);
|
||||||
job.progress = 0;
|
|
||||||
|
|
||||||
|
const payload = job.payload;
|
||||||
const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' };
|
const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' };
|
||||||
const outExt = ext[format] || '.mp4';
|
const outExt = ext[format] || '.mp4';
|
||||||
const outFile = path.join(DOWNLOADS_DIR, `${jobId}${outExt}`);
|
const outFile = path.join(RESULTS_DIR, `${jobId}${outExt}`);
|
||||||
job.outputPath = outFile;
|
|
||||||
|
// Track live progress in memory
|
||||||
|
liveProgress.set(jobId, { progress: 0 });
|
||||||
|
|
||||||
try {
|
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 (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime));
|
||||||
if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime));
|
if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime));
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'convert': {
|
case 'convert': {
|
||||||
// Format conversion (software encoders — mpeg4 for mp4/avi/mkv)
|
if (format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-movflags', '+faststart');
|
||||||
if (format === 'mp4') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-movflags', '+faststart');
|
else if (format === 'webm') args.push('-c:v', 'libvpx', '-b:v', '1M', '-c:a', 'libvorbis');
|
||||||
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', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
|
||||||
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
|
else if (format === 'mkv') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
|
||||||
else if (format === 'mkv') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'compress': {
|
case 'compress': {
|
||||||
// Quality via mpeg4 q:v (1=best, 31=worst) + format selection
|
const crf = quality === 'high' ? '18' : quality === 'low' ? '28' : '23';
|
||||||
const qv = quality === 'high' ? '3' : quality === 'low' ? '15' : '8';
|
args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', crf, '-c:a', 'aac', '-b:a', '128k');
|
||||||
args.push('-c:v', 'mpeg4', '-q:v', qv, '-c:a', 'aac', '-b:a', '128k');
|
|
||||||
if (format === 'mp4' || !format) args.push('-movflags', '+faststart');
|
if (format === 'mp4' || !format) args.push('-movflags', '+faststart');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'audio': {
|
case 'audio': {
|
||||||
args.push('-vn');
|
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');
|
else args.push('-c:a', 'aac', '-b:a', '192k');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'gif': {
|
case 'gif': {
|
||||||
// GIF from video
|
const w = Math.min(payload.info.width || 480, 480);
|
||||||
const w = Math.min(job.info.width || 480, 480);
|
|
||||||
args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0');
|
args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
job.status = 'error';
|
queue.failJob(jobId, 'Неизвестный режим');
|
||||||
return res.status(400).json({ error: 'Неизвестный режим' });
|
return res.status(400).json({ error: 'Неизвестный режим' });
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(outFile);
|
args.push(outFile);
|
||||||
|
|
||||||
// Start ffmpeg in background — respond immediately
|
// Respond immediately, process in background
|
||||||
res.json({ status: 'processing' });
|
res.json({ status: 'processing' });
|
||||||
|
|
||||||
runFFmpeg(args, jobId).then(() => {
|
runFFmpeg(args, jobId, payload.duration).then(() => {
|
||||||
job.status = 'done';
|
|
||||||
job.progress = 100;
|
|
||||||
const outStat = fs.statSync(outFile);
|
const outStat = fs.statSync(outFile);
|
||||||
job.outputSize = outStat.size;
|
const outputSize = outStat.size;
|
||||||
job.savings = job.size > 0 ? Math.round((1 - outStat.size / job.size) * 100) : 0;
|
const savings = payload.size > 0 ? Math.round((1 - outputSize / payload.size) * 100) : 0;
|
||||||
job.downloadUrl = `/video/download/${jobId}${outExt}`;
|
const downloadUrl = `/video/download/${jobId}${outExt}`;
|
||||||
log.info(`Video ${mode}: ${job.originalName} → ${format} (${(outStat.size / 1024 / 1024).toFixed(1)}MB, ${job.savings}% saved)`);
|
|
||||||
|
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 => {
|
}).catch(err => {
|
||||||
job.status = 'error';
|
queue.failJob(jobId, err.message.slice(0, 200));
|
||||||
job.error = 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 });
|
log.error('Video convert error', { error: err.message });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
job.status = 'error';
|
queue.failJob(jobId, err.message);
|
||||||
|
liveProgress.delete(jobId);
|
||||||
log.error('Video convert error', { error: err.message });
|
log.error('Video convert error', { error: err.message });
|
||||||
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
|
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Progress (returns downloadUrl when done)
|
// Progress
|
||||||
router.get('/progress/:jobId', (req, res) => {
|
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' });
|
if (!job) return res.status(404).json({ error: 'Not found' });
|
||||||
const result = { status: job.status, progress: job.progress };
|
|
||||||
if (job.status === 'done') {
|
// Use live progress if available (more up-to-date)
|
||||||
result.downloadUrl = job.downloadUrl;
|
const live = liveProgress.get(req.params.jobId);
|
||||||
result.outputSize = job.outputSize;
|
const progress = live ? live.progress : job.progress;
|
||||||
result.savings = job.savings;
|
|
||||||
|
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') {
|
if (job.status === 'error') {
|
||||||
result.error = job.error || 'Ошибка обработки';
|
result.error = job.error || 'Ошибка обработки';
|
||||||
@ -236,7 +242,7 @@ router.get('/progress/:jobId', (req, res) => {
|
|||||||
// Download result
|
// Download result
|
||||||
router.get('/download/:filename', (req, res) => {
|
router.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath);
|
res.download(filePath);
|
||||||
});
|
});
|
||||||
|
|||||||
57
server.js
57
server.js
@ -1,7 +1,7 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const session = require('express-session');
|
const sessionMiddleware = require('./lib/session');
|
||||||
const log = require('./lib/logger');
|
const log = require('./lib/logger');
|
||||||
const authMiddleware = require('./lib/auth');
|
const authMiddleware = require('./lib/auth');
|
||||||
|
|
||||||
@ -11,22 +11,26 @@ app.set('trust proxy', 1);
|
|||||||
// Security headers
|
// Security headers
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
app.use(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,
|
crossOriginEmbedderPolicy: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Session
|
// Session
|
||||||
app.use(session({
|
app.use(sessionMiddleware);
|
||||||
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',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Static assets (before auth — always public)
|
// Static assets (before auth — always public)
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
@ -51,15 +55,35 @@ app.use(authMiddleware);
|
|||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
// Health check (extended)
|
// Health check (extended)
|
||||||
|
const os = require('os');
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
|
const queue = require('./lib/queue');
|
||||||
|
const wsInfo = require('./lib/ws');
|
||||||
|
const loadavg = os.loadavg();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
memory: {
|
memory: {
|
||||||
rss: Math.round(mem.rss / 1024 / 1024) + 'MB',
|
rss: Math.round(mem.rss / 1024 / 1024) + 'MB',
|
||||||
heap: Math.round(mem.heapUsed / 1024 / 1024) + '/' + Math.round(mem.heapTotal / 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,
|
node: process.version,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
@ -75,7 +99,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
app.get('/download/:filename', (req, res) => {
|
app.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath, 'compressed.zip');
|
res.download(filePath, 'compressed.zip');
|
||||||
});
|
});
|
||||||
@ -88,6 +112,7 @@ app.use(require('./routes/redirects'));
|
|||||||
app.use(require('./routes/svgeditor'));
|
app.use(require('./routes/svgeditor'));
|
||||||
app.use('/video', require('./routes/video'));
|
app.use('/video', require('./routes/video'));
|
||||||
app.use('/pdf', require('./routes/pdf'));
|
app.use('/pdf', require('./routes/pdf'));
|
||||||
|
app.use('/favicon', require('./routes/favicon'));
|
||||||
app.use(require('./routes/pages'));
|
app.use(require('./routes/pages'));
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
@ -113,6 +138,10 @@ const PORT = parseInt(process.env.PORT) || 3000;
|
|||||||
log.info(`Server started on http://localhost:${PORT}`);
|
log.info(`Server started on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
const wsServer = require('./lib/ws');
|
||||||
|
wsServer.attach(server);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
log.info(`${signal} received, shutting down gracefully...`);
|
log.info(`${signal} received, shutting down gracefully...`);
|
||||||
|
|||||||
28
tailwind.config.js
Normal file
28
tailwind.config.js
Normal 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: [],
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user