Initial commit: WA Dev Tools service

This commit is contained in:
treamz 2026-03-21 22:06:15 +03:00
commit 54d220e0f4
38 changed files with 18236 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
uploads/
downloads/
*.log
.idea/
*.bak
.env

115
EDITOR-DOC.md Normal file
View File

@ -0,0 +1,115 @@
# 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 |

250
SVGEDITOR-FEATURES.md Normal file
View File

@ -0,0 +1,250 @@
# 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

3419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Executable file
View File

@ -0,0 +1,29 @@
{
"name": "tinypng",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"description": "",
"dependencies": {
"@mozilla/readability": "^0.6.0",
"archiver": "^7.0.1",
"express": "^5.1.0",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
"iconv-lite": "^0.7.2",
"jsdom": "^28.1.0",
"multer": "^1.4.5-lts.2",
"openid-client": "^6.4.2",
"opentype.js": "^1.3.4",
"passport": "^0.7.0",
"sharp": "^0.34.1"
}
}

View File

@ -0,0 +1,250 @@
# 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

515
public/compress.html Normal file
View File

@ -0,0 +1,515 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Сжать картинки</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
/* Mobile bottom bar */
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
/* Theme variables */
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
--bar-bg: #e0e0e0;
--select-bg: #f0f0f3;
--select-color: #1a1a2e;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
--bar-bg: #0d1828;
--select-bg: #091020;
--select-color: #e0e0e0;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.dropzone-active { border-color: #0054e6 !important; background: rgba(0, 84, 230, 0.05) !important; }
.dropzone-active .drop-icon { transform: scale(1.1) translateY(-4px); }
.drop-icon { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
.savings-bar { height: 6px; border-radius: 3px; background: var(--bar-bg); overflow: hidden; }
.savings-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
@keyframes pulse-border { 0%, 100% { border-color: rgba(0, 84, 230, 0.2); } 50% { border-color: rgba(0, 84, 230, 0.5); } }
.processing { animation: pulse-border 1.5s ease-in-out infinite; }
.preview-grid img { transition: transform 0.2s ease; }
.preview-grid img:hover { transform: scale(1.05); }
progress { appearance: none; height: 4px; border-radius: 2px; overflow: hidden; }
progress::-webkit-progress-bar { background: var(--bar-bg); border-radius: 2px; }
progress::-webkit-progress-value { background: linear-gradient(90deg, #0043b8, #0054e6); border-radius: 2px; transition: width 0.2s; }
select, select option { background: var(--select-bg); color: var(--select-color); }
/* Theme toggle */
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link active" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-2xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<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>
JPEG &middot; PNG &middot; WebP
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Сжать <span class="text-accent">картинки</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">до 50 файлов &middot; до 20 МБ каждый</p>
</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">
<!-- 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 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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p class="text-sm dark:text-gray-400 text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors">
Перетащите файлы сюда или <span class="text-accent font-medium">выберите</span>
</p>
<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">
</div>
<!-- Preview -->
<div id="preview" class="preview-grid grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4 hidden"></div>
<!-- File count badge -->
<div id="fileCount" class="hidden mt-3 text-center">
<span class="inline-flex items-center gap-1.5 text-xs font-mono dark:text-gray-400 text-gray-500 dark:bg-surface-700 bg-gray-100 px-3 py-1 rounded-full">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span id="fileCountText">0 файлов</span>
</span>
</div>
<!-- Controls -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-5">
<div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label>
<select id="format" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
<option value="original">Оригинал</option>
<option value="webp">WebP</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
</select>
</div>
<div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Размер</label>
<select id="resize" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
<option value="0">Не изменять</option>
<option value="4600">4600px</option>
<option value="3600">3600px</option>
<option value="2600">2600px</option>
<option value="1600">1600px</option>
<option value="1200">1200px</option>
<option value="800">800px</option>
</select>
</div>
</div>
<!-- Progress -->
<progress id="progressBar" class="w-full mt-4 hidden" value="0" max="100"></progress>
<!-- Compress button -->
<button id="compressBtn" disabled
class="mt-5 w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent/10 text-accent/40 border border-accent/10 cursor-not-allowed
enabled:bg-accent enabled:text-white enabled:border-accent enabled:cursor-pointer
enabled:hover:bg-accent-bright enabled:hover:shadow-lg enabled:hover:shadow-accent/20
enabled:active:scale-[0.98]">
Сжать
</button>
</div>
<!-- Results -->
<div id="results" class="hidden mt-5 fade-up">
<!-- Stats card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl overflow-hidden shadow-sm dark:shadow-none">
<!-- Summary header -->
<div id="summary" class="flex items-center justify-between px-5 sm:px-7 py-4 border-b dark:border-surface-600 border-gray-200 dark:bg-surface-700/30 bg-gray-50/50">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div>
<div class="text-sm font-semibold dark:text-white text-gray-900" id="summaryTitle">Готово</div>
<div class="text-xs dark:text-gray-500 text-gray-400 font-mono" id="summaryDetail"></div>
</div>
</div>
<div class="text-right">
<div class="text-lg font-bold font-mono" id="summaryPercent"></div>
<div class="text-xs dark:text-gray-500 text-gray-400">экономия</div>
</div>
</div>
<!-- Stats table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider border-b dark:border-surface-600 border-gray-200">
<th class="text-left px-5 py-3">Файл</th>
<th class="text-right px-3 py-3">Было</th>
<th class="text-right px-3 py-3">Стало</th>
<th class="text-right px-5 py-3 w-32">Экономия</th>
</tr>
</thead>
<tbody id="statsBody"></tbody>
</table>
</div>
</div>
<!-- Download button -->
<a id="downloadBtn" href="#" download
class="mt-4 flex items-center justify-center gap-2 w-full py-3.5 rounded-xl font-semibold text-sm tracking-wide
bg-accent text-white hover:bg-accent-bright transition-all duration-300
hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Скачать архив
</a>
</div>
<!-- Error -->
<div id="errorBlock" class="hidden mt-5 bg-danger/10 border border-danger/20 rounded-xl px-5 py-4 text-sm text-danger font-medium fade-up"></div>
</div>
</div><!-- /main-content -->
<script>
// Theme toggle
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
const $ = id => document.getElementById(id);
const dropzone = $('dropzone');
const fileInput = $('fileInput');
const progressBar = $('progressBar');
const compressBtn = $('compressBtn');
const results = $('results');
const errorBlock = $('errorBlock');
const preview = $('preview');
let selectedFiles = null;
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
}
function pluralFiles(n) {
const mod = n % 10;
const mod100 = n % 100;
if (mod === 1 && mod100 !== 11) return n + ' файл';
if (mod >= 2 && mod <= 4 && (mod100 < 12 || mod100 > 14)) return n + ' файла';
return n + ' файлов';
}
// Drag & drop
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dropzone-active'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dropzone-active'));
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('dropzone-active'); processFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', e => processFiles(e.target.files));
function processFiles(files) {
results.classList.add('hidden');
errorBlock.classList.add('hidden');
preview.innerHTML = '';
preview.classList.add('hidden');
compressBtn.disabled = true;
if (!files.length) return;
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024);
if (!valid.length) {
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP до 20 МБ.');
return;
}
selectedFiles = valid;
$('fileCount').classList.remove('hidden');
$('fileCountText').textContent = pluralFiles(valid.length);
// Show previews (max 8)
const shown = valid.slice(0, 8);
shown.forEach(file => {
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'w-full aspect-square object-cover rounded-lg border dark:border-surface-600 border-gray-200';
preview.appendChild(img);
preview.classList.remove('hidden');
};
reader.readAsDataURL(file);
});
if (valid.length > 8) {
const more = document.createElement('div');
more.className = 'w-full aspect-square rounded-lg border dark:border-surface-600 border-gray-200 dark:bg-surface-700 bg-gray-100 flex items-center justify-center text-xs dark:text-gray-500 text-gray-400 font-mono';
more.textContent = '+' + (valid.length - 8);
preview.appendChild(more);
preview.classList.remove('hidden');
}
compressBtn.disabled = false;
compressBtn.textContent = 'Сжать';
}
function showError(msg) {
errorBlock.textContent = msg;
errorBlock.classList.remove('hidden');
}
compressBtn.addEventListener('click', () => {
if (!selectedFiles || !selectedFiles.length) return;
compressBtn.disabled = true;
compressBtn.textContent = 'Сжимаю...';
results.classList.add('hidden');
errorBlock.classList.add('hidden');
progressBar.hidden = false;
progressBar.value = 0;
const formData = new FormData();
selectedFiles.forEach(f => formData.append('images', f));
formData.append('resize', $('resize').value);
formData.append('format', $('format').value);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/compress');
xhr.responseType = 'json';
xhr.upload.onprogress = e => {
if (e.lengthComputable) progressBar.value = (e.loaded / e.total) * 100;
};
xhr.onload = () => {
progressBar.hidden = true;
compressBtn.textContent = 'Сжать';
compressBtn.disabled = false;
if (xhr.status === 200 && xhr.response && xhr.response.success) {
renderResults(xhr.response);
} else if (xhr.status === 429) {
showError('Слишком много запросов. Подождите минуту.');
} else {
const msg = xhr.response && xhr.response.error ? xhr.response.error : 'Ошибка при сжатии.';
showError(msg);
}
};
xhr.onerror = () => {
progressBar.hidden = true;
compressBtn.textContent = 'Сжать';
compressBtn.disabled = false;
showError('Ошибка сети. Повторите позже.');
};
xhr.send(formData);
});
function renderResults(data) {
const { stats, downloadUrl } = data;
// Summary
const totalOrig = stats.reduce((s, f) => s + f.originalSize, 0);
const totalComp = stats.reduce((s, f) => s + f.compressedSize, 0);
const totalSavings = totalOrig > 0 ? Math.round((1 - totalComp / totalOrig) * 100) : 0;
$('summaryDetail').textContent = `${formatSize(totalOrig)} → ${formatSize(totalComp)}`;
const pctEl = $('summaryPercent');
pctEl.textContent = (totalSavings > 0 ? '-' : '') + totalSavings + '%';
pctEl.className = 'text-lg font-bold font-mono ' + savingsColor(totalSavings);
// Table
const tbody = $('statsBody');
tbody.innerHTML = '';
stats.forEach((f, i) => {
const row = document.createElement('tr');
row.className = 'border-b dark:border-surface-600/50 border-gray-100 last:border-0' + (i % 2 === 0 ? ' dark:bg-surface-700/20 bg-gray-50/50' : '');
const sColor = savingsColor(f.savings);
row.innerHTML = `
<td class="px-5 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600 truncate max-w-[180px]" title="${f.outputFilename}">${f.outputFilename}</td>
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-500 text-gray-400">${formatSize(f.originalSize)}</td>
<td class="text-right px-3 py-2.5 font-mono text-xs dark:text-gray-300 text-gray-600">${formatSize(f.compressedSize)}</td>
<td class="px-5 py-2.5">
<div class="flex items-center gap-2 justify-end">
<div class="savings-bar flex-1 max-w-[60px]">
<div class="savings-bar-fill ${barColor(f.savings)}" style="width: ${Math.max(0, Math.min(100, f.savings))}%"></div>
</div>
<span class="font-mono text-xs font-medium ${sColor} w-10 text-right">${f.savings > 0 ? '-' : ''}${f.savings}%</span>
</div>
</td>`;
tbody.appendChild(row);
});
// 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>
</body>
</html>

284
public/converter.html Normal file
View File

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Converter — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<script src="/vendor/marked.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
sans: ['Manrope', 'sans-serif'],
},
colors: {
surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' },
accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root{--bg:#f5f5f7;--bg-grad1:rgba(0,67,184,.04);--bg-grad2:rgba(0,150,60,.03);--surface-800:rgba(255,255,255,0.85);--surface-700:#f0f0f3;--surface-600:#dde0e6;--text-primary:#1a1a2e;--text-secondary:#555;--text-muted:#888;--select-bg:#f0f0f3;--select-color:#1a1a2e}
.dark{--bg:#060c18;--bg-grad1:rgba(0,84,230,.03);--bg-grad2:rgba(0,67,184,.02);--surface-800:rgba(9,16,32,0.8);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--select-bg:#091020;--select-color:#e0e0e0}
body{background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,var(--bg-grad1) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,var(--bg-grad2) 0%,transparent 50%);min-height:100vh;transition:background .3s}
.dark .noise::before{content:'';position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");pointer-events:none;z-index:0}
.theme-toggle{position:relative;width:44px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s}
.dark .theme-toggle{background:#162040}.theme-toggle{background:#dde0e6}
.theme-toggle-knob{position:absolute;top:2px;width:20px;height:20px;border-radius:50%;transition:left .3s,background .3s;display:flex;align-items:center;justify-content:center}
.dark .theme-toggle-knob{left:22px;background:#0d1828}.theme-toggle-knob{left:2px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></a>
<a href="/compress" class="sidebar-link" title="Картинки"><svg width="22" height="22" 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></a>
<a href="/md" class="sidebar-link" title="Markdown"><svg width="22" height="22" 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></a>
<a href="/placeholder" class="sidebar-link" title="Placeholder"><svg width="22" height="22" 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></a>
<a href="/parser" class="sidebar-link" title="Parser"><svg width="22" height="22" 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></a>
<a href="/password" class="sidebar-link" title="Password"><svg width="22" height="22" 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></a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer"><svg width="22" height="22" 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></a>
<a href="/converter" class="sidebar-link active" title="Converter"><svg width="22" height="22" 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></a>
<a href="/formatter" class="sidebar-link" title="Formatter"><svg width="22" height="22" 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></a>
<a href="/editor" class="sidebar-link" title="Editor"><svg width="22" height="22" 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></a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-3xl mx-auto px-4 py-8 sm:py-12">
<div class="text-center mb-8 fade-up">
<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>
HTML &harr; Markdown
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Format <span class="text-accent">Converter</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">конвертация между HTML и Markdown</p>
</div>
<!-- Tool Toggle -->
<div class="flex gap-2 mb-5 fade-up-delay">
<button class="tool-btn active flex-1 px-4 py-3 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="htmlToMd">
<div class="text-sm font-semibold">HTML &rarr; Markdown</div>
</button>
<button class="tool-btn flex-1 px-4 py-3 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="mdToHtml">
<div class="text-sm font-semibold">Markdown &rarr; HTML</div>
</button>
</div>
<!-- Processing 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="mb-4">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Input</label>
<textarea id="inputArea" rows="10" placeholder="Вставьте HTML..."
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none focus:border-accent/50 transition-colors"></textarea>
</div>
<button id="processBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
Конвертировать
</button>
<div id="outputBlock" class="mt-5 hidden">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium dark:text-gray-500 text-gray-400 font-mono uppercase tracking-wider">Output</label>
<div class="flex items-center gap-2">
<span id="stats" class="text-[10px] font-mono dark:text-gray-600 text-gray-400"></span>
<button id="copyBtn" class="px-3 py-1 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">Копировать</button>
<button id="clearBtn" class="px-3 py-1 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-400 text-gray-500 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">Очистить</button>
</div>
</div>
<textarea id="outputArea" rows="12" readonly
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none transition-colors"></textarea>
</div>
</div>
</div>
</div>
<script>
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
let activeTool = 'htmlToMd';
const $ = id => document.getElementById(id);
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeTool = btn.dataset.tool;
$('inputArea').placeholder = activeTool === 'htmlToMd' ? 'Вставьте HTML...' : 'Вставьте Markdown...';
});
});
$('processBtn').addEventListener('click', () => {
const input = $('inputArea').value;
if (!input.trim()) return;
let output = '';
try {
output = activeTool === 'htmlToMd' ? htmlToMarkdown(input) : mdToHtml(input);
} catch (e) { output = 'Error: ' + e.message; }
$('outputArea').value = output;
$('outputBlock').classList.remove('hidden');
const diff = output.length - input.length;
$('stats').textContent = `${input.length} \u2192 ${output.length} (${diff > 0 ? '+' : ''}${diff})`;
});
$('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText($('outputArea').value).then(() => {
const b = $('copyBtn'); const o = b.textContent;
b.textContent = '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!'; setTimeout(() => { b.textContent = o; }, 1500);
});
});
$('clearBtn').addEventListener('click', () => {
$('inputArea').value = ''; $('outputArea').value = ''; $('outputBlock').classList.add('hidden');
});
// ==================== HTML → Markdown ====================
function htmlToMarkdown(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return nodesToMd(doc.body.childNodes).replace(/\n{3,}/g, '\n\n').trim();
}
function nodesToMd(nodes) {
let md = '';
for (const node of nodes) {
if (node.nodeType === 3) md += node.textContent.replace(/\s+/g, ' ');
else if (node.nodeType === 1) md += elemToMd(node);
}
return md;
}
function elemToMd(el) {
const tag = el.tagName.toLowerCase();
const inner = () => nodesToMd(el.childNodes);
switch (tag) {
case 'h1': return '\n\n# ' + inner().trim() + '\n\n';
case 'h2': return '\n\n## ' + inner().trim() + '\n\n';
case 'h3': return '\n\n### ' + inner().trim() + '\n\n';
case 'h4': return '\n\n#### ' + inner().trim() + '\n\n';
case 'h5': return '\n\n##### ' + inner().trim() + '\n\n';
case 'h6': return '\n\n###### ' + inner().trim() + '\n\n';
case 'p': return '\n\n' + inner().trim() + '\n\n';
case 'br': return '\n';
case 'hr': return '\n\n---\n\n';
case 'strong': case 'b': return '**' + inner().trim() + '**';
case 'em': case 'i': return '*' + inner().trim() + '*';
case 'del': case 's': return '~~' + inner().trim() + '~~';
case 'a': return '[' + inner().trim() + '](' + (el.getAttribute('href') || '') + ')';
case 'img': return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';
case 'code':
if (el.parentElement && el.parentElement.tagName.toLowerCase() === 'pre') return el.textContent;
return '`' + el.textContent + '`';
case 'pre': {
const code = el.querySelector('code');
const lang = code ? (code.className.match(/language-(\w+)/) || [])[1] || '' : '';
return '\n\n```' + lang + '\n' + (code ? code.textContent : el.textContent) + '\n```\n\n';
}
case 'blockquote': return '\n\n' + inner().trim().split('\n').map(l => '> ' + l).join('\n') + '\n\n';
case 'ul': {
let r = '\n';
for (const c of el.children) {
if (c.tagName && c.tagName.toLowerCase() === 'li') r += '- ' + nodesToMd(c.childNodes).trim() + '\n';
}
return r + '\n';
}
case 'ol': {
let r = '\n', n = 1;
for (const c of el.children) {
if (c.tagName && c.tagName.toLowerCase() === 'li') { r += n + '. ' + nodesToMd(c.childNodes).trim() + '\n'; n++; }
}
return r + '\n';
}
case 'table': return '\n\n' + tableToMd(el) + '\n\n';
case 'script': case 'style': return '';
default: return inner();
}
}
function tableToMd(table) {
const rows = [];
table.querySelectorAll('tr').forEach(tr => {
const cells = [];
tr.querySelectorAll('th, td').forEach(c => cells.push(c.textContent.trim()));
if (cells.length) rows.push(cells);
});
if (!rows.length) return '';
const cols = Math.max(...rows.map(r => r.length));
rows.forEach(r => { while (r.length < cols) r.push(''); });
const w = Array(cols).fill(3);
for (let c = 0; c < cols; c++) for (const r of rows) w[c] = Math.max(w[c], r[c].length);
let md = '| ' + rows[0].map((c, i) => c.padEnd(w[i])).join(' | ') + ' |\n';
md += '| ' + w.map(v => '-'.repeat(v)).join(' | ') + ' |\n';
for (let r = 1; r < rows.length; r++) md += '| ' + rows[r].map((c, i) => c.padEnd(w[i])).join(' | ') + ' |\n';
return md.trimEnd();
}
// ==================== Markdown → HTML ====================
function mdToHtml(md) {
return marked.parse(md);
}
</script>
</body>
</html>

170
public/editor.css Normal file
View File

@ -0,0 +1,170 @@
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#060c18;--surface-800:rgba(9,16,32,0.95);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--accent:#0054e6;--accent-dim:#0043b8;--accent-bg:rgba(0,84,230,0.08);--danger:#ff5252;--warn:#ffd600}
body{font-family:'Manrope',sans-serif;font-size:13px;background:var(--bg);color:var(--text-secondary);overflow:hidden;height:100vh;width:100vw;user-select:none}
input,select,button,textarea{font-family:inherit;font-size:inherit}
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:100;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px)}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1;overflow-y:auto;scrollbar-width:none}
.sidebar-nav::-webkit-scrollbar{display:none}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative;flex-shrink:0}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:var(--accent);background:var(--accent-bg)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--accent)}
/* Main layout */
.editor-wrap{margin-left:56px;height:100vh;display:flex;flex-direction:column}
/* Toolbar */
.toolbar{height:44px;background:var(--surface-800);border-bottom:1px solid var(--surface-600);display:flex;align-items:center;padding:0 12px;gap:4px;flex-shrink:0;z-index:20}
.toolbar-group{display:flex;align-items:center;gap:2px}
.toolbar-sep{width:1px;height:24px;background:var(--surface-600);margin:0 6px}
.tb{width:34px;height:34px;border:none;border-radius:8px;background:transparent;color:var(--text-primary);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;position:relative}
.tb:hover{color:#fff;background:rgba(255,255,255,.08)}
.tb.active{color:var(--accent);background:var(--accent-bg)}
.tb:disabled{opacity:.3;cursor:default;pointer-events:none}
.tb svg{width:18px;height:18px}
.tb-label{font-size:11px;font-weight:500;padding:0 8px;white-space:nowrap}
.toolbar-right{margin-left:auto;display:flex;align-items:center;gap:4px}
/* Tool Options Bar */
.tool-options{height:36px;background:var(--surface-700);border-bottom:1px solid var(--surface-600);display:none;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;z-index:19}
.tool-options.visible{display:flex}
.tool-options label{font-size:11px;color:var(--text-muted);white-space:nowrap}
.tool-options input[type="range"]{width:80px;accent-color:var(--accent);height:4px;cursor:pointer}
.tool-options input[type="number"]{width:60px;background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 6px;border-radius:5px;text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px}
.tool-options input[type="number"]:focus{border-color:var(--accent);outline:none}
.tool-options select{background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 8px;border-radius:5px;cursor:pointer;font-size:11px}
.tool-options select:focus{border-color:var(--accent);outline:none}
.tool-options .opt-sep{width:1px;height:20px;background:var(--surface-600)}
.opt-btn{padding:3px 10px;border:1px solid var(--surface-600);border-radius:5px;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:11px;transition:all .15s}
.opt-btn:hover{border-color:var(--text-muted)}
.opt-btn.primary{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.opt-btn.primary:hover{background:var(--accent-dim)}
.opt-toggle{padding:3px 8px;border:1px solid var(--surface-600);border-radius:5px;background:transparent;color:var(--text-muted);cursor:pointer;font-size:11px;transition:all .15s}
.opt-toggle.on{color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
/* Viewport */
.viewport{flex:1;overflow:hidden;position:relative;background:#2c2c30}
.viewport-checker{display:none}
.canvas-container{position:absolute;transform-origin:0 0;cursor:crosshair;background:#2c2c30}
.canvas-container.has-transparency{background-image:linear-gradient(45deg,#444 25%,transparent 25%),linear-gradient(-45deg,#444 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#444 75%),linear-gradient(-45deg,transparent 75%,#444 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#333}
.canvas-container canvas{position:absolute;top:0;left:0}
/* Drop zone */
.dropzone{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:10;cursor:pointer;transition:background .2s}
.dropzone:hover{background:rgba(0,84,230,0.03)}
.dropzone.drag-over{background:rgba(0,84,230,0.06);border:2px dashed var(--accent)}
.dropzone-icon{width:64px;height:64px;border-radius:16px;background:var(--surface-700);border:2px dashed var(--surface-600);display:flex;align-items:center;justify-content:center;margin-bottom:16px;transition:all .3s}
.dropzone:hover .dropzone-icon{border-color:var(--accent);background:var(--accent-bg)}
.dropzone-icon svg{color:var(--text-muted);width:28px;height:28px}
.dropzone-text{font-size:15px;color:var(--text-secondary);margin-bottom:6px;font-weight:500}
.dropzone-hint{font-size:12px;color:var(--text-muted)}
.dropzone.hidden{display:none}
/* Status Bar */
.statusbar{height:28px;background:var(--surface-800);border-top:1px solid var(--surface-600);display:flex;align-items:center;padding:0 12px;gap:16px;flex-shrink:0;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-secondary)}
.statusbar span{white-space:nowrap}
.status-accent{color:var(--accent)}
/* Export Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);z-index:200;display:none;align-items:center;justify-content:center}
.modal-overlay.visible{display:flex}
.modal{background:var(--surface-700);border:1px solid var(--surface-600);border-radius:12px;padding:24px;width:380px;max-width:90vw}
.modal h3{font-size:16px;color:var(--text-primary);margin-bottom:16px;font-weight:600}
.modal-row{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.modal-row label{font-size:12px;color:var(--text-muted);width:70px;flex-shrink:0}
.modal-row input,.modal-row select{flex:1;background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:6px 10px;border-radius:6px;font-size:12px}
.modal-row input:focus,.modal-row select:focus{border-color:var(--accent);outline:none}
.modal-warning{font-size:11px;color:var(--warn);margin-bottom:12px;display:none}
.modal-warning.visible{display:block}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.modal-actions button{padding:6px 16px;border-radius:6px;border:1px solid var(--surface-600);background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
.modal-actions button:hover{border-color:var(--text-muted)}
.modal-actions button.primary{background:var(--accent);color:#000;border-color:var(--accent)}
.modal-actions button.primary:hover{background:var(--accent-dim)}
/* Text overlay — Figma-style inline editor */
.text-overlay{position:absolute;z-index:15;display:none;pointer-events:auto;transform-origin:top left}
.text-editor{background:rgba(0,84,230,0.04);border:1.5px solid rgba(0,84,230,0.5);outline:none;padding:3px 6px;min-width:4px;min-height:1.2em;white-space:pre-wrap;word-break:break-word;cursor:text;caret-color:currentColor;user-select:text;-webkit-user-select:text;line-height:1.4;border-radius:3px;transition:border-color .15s}
.text-editor:focus{border-color:var(--accent);background:rgba(0,84,230,0.07);box-shadow:0 0 0 3px rgba(0,84,230,0.1)}
.text-editor:empty::before{content:'Введите текст…';color:rgba(255,255,255,0.25);pointer-events:none}
/* Tab Bar */
.tab-bar{height:30px;background:var(--surface-800);border-bottom:1px solid var(--surface-600);display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden;flex-shrink:0;scrollbar-width:none;z-index:18}
.tab-bar::-webkit-scrollbar{display:none}
.tab-item{display:flex;align-items:center;gap:5px;padding:0 8px 0 12px;border-right:1px solid var(--surface-600);white-space:nowrap;cursor:pointer;font-size:11px;color:var(--text-muted);flex-shrink:0;max-width:160px;transition:color .15s;position:relative}
.tab-item:hover{color:var(--text-secondary);background:rgba(255,255,255,.03)}
.tab-item.active{color:var(--text-primary);background:var(--surface-700);border-bottom:2px solid var(--accent)}
.tab-name{flex:1;overflow:hidden;text-overflow:ellipsis;min-width:40px}
.tab-close{width:16px;height:16px;border:none;background:transparent;color:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:3px;flex-shrink:0;font-size:14px;line-height:1;padding:0;transition:all .15s}
.tab-item:hover .tab-close,.tab-item.active .tab-close{color:var(--text-muted)}
.tab-close:hover{color:var(--danger)!important;background:rgba(255,82,82,.12)}
.tab-add{width:32px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s;border-right:1px solid var(--surface-600)}
.tab-add:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
/* Color picker in toolbar */
.color-swatch{width:22px;height:22px;border-radius:6px;border:2px solid var(--surface-600);cursor:pointer;overflow:hidden;position:relative}
.color-swatch input{position:absolute;inset:-8px;width:calc(100% + 16px);height:calc(100% + 16px);cursor:pointer;opacity:0}
/* Crop overlay */
.crop-overlay{position:absolute;inset:0;z-index:12;display:none;pointer-events:none}
.crop-overlay.visible{display:block;pointer-events:auto}
.crop-handle{position:absolute;width:10px;height:10px;background:var(--accent);border:1px solid #000;border-radius:2px;cursor:pointer;z-index:13}
/* Context menu */
.ctx-menu{position:fixed;z-index:300;background:var(--surface-700);border:1px solid var(--surface-600);border-radius:10px;padding:4px;min-width:180px;box-shadow:0 8px 32px rgba(0,0,0,.5);display:none;backdrop-filter:blur(12px)}
.ctx-menu.visible{display:block}
.ctx-item{display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:6px;cursor:pointer;color:var(--text-secondary);font-size:12px;white-space:nowrap;transition:background .1s}
.ctx-item:hover{background:rgba(255,255,255,.06);color:var(--text-primary)}
.ctx-item.disabled{opacity:.3;pointer-events:none}
.ctx-item svg{width:15px;height:15px;flex-shrink:0;color:var(--text-muted)}
.ctx-item:hover svg{color:var(--text-secondary)}
.ctx-item .ctx-shortcut{margin-left:auto;font-size:10px;color:var(--text-muted);font-family:'JetBrains Mono',monospace}
.ctx-sep{height:1px;background:var(--surface-600);margin:4px 8px}
/* Scrollbar */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--surface-600);border-radius:3px}
/* Layer Panel */
.layer-panel{position:absolute;right:12px;bottom:40px;width:240px;max-height:400px;background:var(--surface-800);border:1px solid var(--surface-600);border-radius:10px;z-index:15;display:none;flex-direction:column;backdrop-filter:blur(12px);overflow:hidden}
.layer-panel.visible{display:flex}
.layer-panel-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid var(--surface-600);font-size:11px;font-weight:600;color:var(--text-primary)}
.layer-panel-actions{display:flex;gap:2px}
.lpa-btn{width:28px;height:28px;border:none;border-radius:6px;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;padding:0}
.lpa-btn:hover{color:var(--text-secondary);background:rgba(255,255,255,.06)}
.lpa-btn:disabled{opacity:.3;cursor:default;pointer-events:none}
.layer-list{flex:1;overflow-y:auto;padding:4px}
.layer-item{display:flex;align-items:center;gap:6px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .1s;user-select:none}
.layer-item:hover{background:rgba(255,255,255,.04)}
.layer-item.active{background:var(--accent-bg);outline:1px solid rgba(0,84,230,.3)}
.layer-item.drag-over-top{box-shadow:0 -2px 0 var(--accent)}
.layer-item.drag-over-bottom{box-shadow:0 2px 0 var(--accent)}
.layer-vis{width:20px;height:20px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:4px;flex-shrink:0;font-size:12px;padding:0}
.layer-vis:hover{color:var(--text-secondary)}
.layer-vis.off{opacity:.3}
.layer-thumb{width:32px;height:32px;border-radius:4px;border:1px solid var(--surface-600);background-image:linear-gradient(45deg,#333 25%,transparent 25%),linear-gradient(-45deg,#333 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#333 75%),linear-gradient(-45deg,transparent 75%,#333 75%);background-size:8px 8px;background-position:0 0,0 4px,4px -4px,-4px 0;flex-shrink:0;overflow:hidden;position:relative}
.layer-thumb canvas{width:100%;height:100%;display:block}
.layer-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.layer-name{font-size:11px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.layer-name-input{font-size:11px;color:var(--text-secondary);background:var(--surface-600);border:1px solid var(--accent);border-radius:3px;padding:1px 4px;outline:none;width:100%;font-family:inherit}
.layer-meta{font-size:9px;color:var(--text-muted);font-family:'JetBrains Mono',monospace}
.layer-props{padding:6px 10px;border-top:1px solid var(--surface-600);display:flex;flex-direction:column;gap:5px}
.layer-props label{font-size:10px;color:var(--text-muted)}
.layer-props select{background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 6px;border-radius:4px;font-size:10px;cursor:pointer;width:100%;font-family:inherit}
.layer-props select:focus{border-color:var(--accent);outline:none}
.layer-props input[type="range"]{width:100%;accent-color:var(--accent);height:3px}
.lp-row{display:flex;align-items:center;gap:6px}
.lp-row label{width:55px;flex-shrink:0}
.lp-row span{font-size:10px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;width:28px;text-align:right;flex-shrink:0}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 8px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:2px;flex:none}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.editor-wrap{margin-left:0;height:calc(100vh - 52px)}
}

272
public/editor.html Normal file
View File

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor — images.wadevelop.ru</title>
<link href="/vendor/fonts.css" rel="stylesheet">
<link rel="stylesheet" href="/editor.css">
</head>
<body>
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></a>
<a href="/compress" class="sidebar-link" title="Картинки"><svg width="22" height="22" 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></a>
<a href="/md" class="sidebar-link" title="Markdown"><svg width="22" height="22" 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></a>
<a href="/placeholder" class="sidebar-link" title="Placeholder"><svg width="22" height="22" 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></a>
<a href="/parser" class="sidebar-link" title="Parser"><svg width="22" height="22" 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></a>
<a href="/password" class="sidebar-link" title="Password"><svg width="22" height="22" 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></a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer"><svg width="22" height="22" 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></a>
<a href="/converter" class="sidebar-link" title="Converter"><svg width="22" height="22" 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></a>
<a href="/formatter" class="sidebar-link" title="Formatter"><svg width="22" height="22" 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></a>
<a href="/editor" class="sidebar-link active" title="Editor"><svg width="22" height="22" 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></a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
</nav>
<!-- Editor -->
<div class="editor-wrap">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-group">
<button class="tb" id="btnOpen" title="Open (Ctrl+O)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg></button>
<button class="tb" id="btnExport" title="Export (Ctrl+S)" disabled><svg 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.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Drawing tools -->
<div class="toolbar-group" id="toolsGroup">
<button class="tb" data-tool="select" title="Выделение (V)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path stroke-linecap="round" stroke-linejoin="round" d="M13 13l6 6"/></svg></button>
<button class="tb" data-tool="crop" title="Вырезать (C)" disabled><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5m0 9V18A2.25 2.25 0 0118 20.25h-1.5m-9 0H6A2.25 2.25 0 013.75 18v-1.5"/></svg></button>
<button class="tb" data-tool="brush" title="Кисть (B)" disabled><svg 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></button>
<button class="tb" data-tool="eraser" title="Ластик (E)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.505 14.706L10.293 19.5H14.5m-9-4.794l8.5-8.5a2 2 0 012.828 0l2.466 2.466a2 2 0 010 2.828l-8.5 8.5H6.706a2 2 0 01-1.414-.586l-1.2-1.2a2 2 0 010-2.828z"/><path stroke-linecap="round" d="M8 21h12"/></svg></button>
<button class="tb" data-tool="line" title="Линия (L)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="5" y1="19" x2="19" y2="5" stroke-linecap="round"/></svg></button>
<button class="tb" data-tool="rect" title="Прямоугольник (U)" disabled><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<button class="tb" data-tool="circle" title="Круг (O)" disabled><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><ellipse cx="12" cy="12" rx="9" ry="9"/></svg></button>
<button class="tb" data-tool="arrow" title="Стрелка (A)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"/></svg></button>
<button class="tb" data-tool="text" title="Текст (T)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12.2,19.6V4H6.9L5.8,5V4.4l0.9-0.8h11.8l0.6,0.8V5l-0.8-1h-5.7v15.6H12.2z"/>
</svg></button>
<button class="tb" data-tool="eyedropper" title="Пипетка (I)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20.9,6c0-0.6-0.2-1.2-0.7-1.7c-0.4-0.4-1-0.7-1.7-0.7c-0.6,0-1.2,0.2-1.7,0.7l-3,3l-1.2-1.2c0,0-0.1,0-0.2,0
c0,0,0,0.1,0,0.2l1.2,1.2l-7.8,7.8c-0.4,0.4-0.7,1-0.7,1.7c0,0.6,0.2,1.1,0.6,1.6l-2.3,2.3c0,0,0,0.1,0,0.2c0,0,0.1,0,0.1,0
s0.1,0,0.1,0L6,18.7c0.4,0.4,1,0.6,1.6,0.6c0.6,0,1.2-0.2,1.7-0.7l7.8-7.8l1.2,1.2c0,0,0.1,0,0.1,0s0.1,0,0.1,0c0,0,0-0.1,0-0.2
l-1.2-1.2l3-3C20.6,7.3,20.9,6.7,20.9,6z M9.1,18.5c-0.8,0.8-2.2,0.8-3,0c-0.4-0.4-0.6-0.9-0.6-1.5c0-0.6,0.2-1.1,0.6-1.5l7.8-7.8
l3,3L9.1,18.5z M20,7.5l-3,3l-3-3l3-3c0.4-0.4,0.9-0.6,1.5-0.6c0.6,0,1.1,0.2,1.5,0.6c0.4,0.4,0.6,0.9,0.6,1.5
C20.6,6.6,20.4,7.1,20,7.5C20,7.5,20,7.5,20,7.5z"/>
</svg></button>
<button class="tb" data-tool="fill" title="Заливка (G)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <path d="M20.5,19.5c0,1.3-1,2.3-2.3,2.3c-1.3,0-2.3-1-2.3-2.3c0-1.3,2.1-4.5,2.3-4.5C18.4,15,20.5,18.2,20.5,19.5z"/>
<path d="M10.4,5L10.4,5L7.4,1.9L7.2,2.1l3,3l-6.8,6.8c-0.4,0.4-0.7,1-0.7,1.6s0.2,1.2,0.7,1.6L8.3,20c0.4,0.4,1,0.7,1.6,0.7
c0.6,0,1.2-0.2,1.6-0.7l6.9-6.9L10.4,5z M3,13.5c0-0.5,0.2-1.1,0.6-1.5l6.8-6.8l7.7,7.7l-4,4H5.7h0L3.6,15C3.2,14.6,3,14,3,13.5z"
/>
</svg></button>
<button class="tb" data-tool="clone" title="Штамп (S)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75"/><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3h9.75a1.125 1.125 0 011.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125H8.25a1.125 1.125 0 01-1.125-1.125v-9.75c0-.621.504-1.125 1.125-1.125z"/></svg></button>
<button class="tb" data-tool="lasso" title="Лассо (W)" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 2" d="M12 4c-2 1.5-5 2-7 5s-1 6 1 8 5 2 8 1 5-4 5-7-2-6-5-7"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 4c1 1 3 2 3 4"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Transform -->
<div class="toolbar-group">
<button class="tb" id="btnRotCCW" title="Повернуть CCW" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/></svg></button>
<button class="tb" id="btnRotCW" title="Повернуть CW" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/></svg></button>
<button class="tb" id="btnFlipH" title="Flip H" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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></button>
<button class="tb" id="btnFlipV" title="Flip V" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"/></svg></button>
<button class="tb" id="btnResize" title="Resize" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg></button>
<button class="tb" id="btnAdjust" title="Adjustments" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/></svg></button>
<button class="tb" id="btnBlur" title="Размытие" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9" stroke-dasharray="3 2"/><circle cx="12" cy="12" r="5" opacity=".5"/><circle cx="12" cy="12" r="2" opacity=".3"/></svg></button>
<button class="tb" id="btnSharpen" title="Резкость" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Filters -->
<div class="toolbar-group" id="filtersGroup">
<button class="tb" id="btnGrayscale" title="Ч/Б" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 3v18" stroke-width="1.5"/><path d="M12 3a9 9 0 010 18" fill="currentColor" opacity=".35"/></svg>
</button>
<button class="tb" id="btnSepia" title="Сепия" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 15l-5-5-4 4-3-2-4 4"/></svg>
</button>
<button class="tb" id="btnInvert" title="Инверсия" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 3v18" stroke-width="1"/><rect x="3" y="3" width="9" height="18" fill="currentColor" opacity=".5" rx="2"/></svg>
</button>
<button class="tb" id="btnAutoLevels" title="Авто-уровни" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18h2m0 0a2 2 0 104 0m-4 0a2 2 0 114 0m0 0h12M3 12h6m0 0a2 2 0 104 0m-4 0a2 2 0 114 0m0 0h8M3 6h10m0 0a2 2 0 104 0m-4 0a2 2 0 114 0m0 0h4"/></svg>
</button>
</div>
<div class="toolbar-sep"></div>
<!-- Undo/Redo -->
<div class="toolbar-group">
<button class="tb" id="btnUndo" title="Undo (Ctrl+Z)" disabled><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/></svg></button>
<button class="tb" id="btnRedo" title="Redo (Ctrl+Shift+Z)" disabled><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/></svg></button>
</div>
<!-- Right side: color + zoom -->
<div class="toolbar-right">
<button class="tb" id="btnLayers" title="Слои"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 2.25l9.75 5.25L12 12.75 2.25 7.5 12 2.25z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l9.75 5.25L21.75 12"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 16.5l9.75 5.25 9.75-5.25"/></svg></button>
<div class="toolbar-sep"></div>
<div class="color-swatch" id="colorSwatch" title="Color">
<input type="color" id="colorPicker" value="#0054e6">
</div>
<div class="toolbar-sep"></div>
<button class="tb" id="btnZoomOut" title="Zoom Out"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><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.607zM13.5 10.5h-6"/></svg></button>
<span id="zoomLabel" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);min-width:40px;text-align:center">100%</span>
<button class="tb" id="btnZoomIn" title="Zoom In"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><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.607zM10.5 7.5v6m3-3h-6"/></svg></button>
<button class="tb" id="btnFit" title="Fit (Ctrl+0)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m9 0h4.5v4.5m0 9v4.5h-4.5m-9 0H3.75v-4.5"/></svg></button>
</div>
</div>
<!-- Tool Options (context-sensitive) -->
<div class="tool-options" id="toolOptions"></div>
<!-- Tab Bar -->
<div class="tab-bar" id="tabBar"></div>
<!-- Viewport -->
<div class="viewport" id="viewport">
<div class="viewport-checker"></div>
<div class="canvas-container" id="canvasContainer">
<canvas id="canvasMain"></canvas>
<canvas id="canvasOverlay"></canvas>
</div>
<canvas id="canvasUI" style="position:absolute;inset:0;z-index:5;pointer-events:none"></canvas>
<!-- Drop zone -->
<div class="dropzone" id="dropzone">
<div class="dropzone-icon">
<svg 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 class="dropzone-text">Перетащите изображение, вставьте из буфера или нажмите</div>
<div class="dropzone-hint">Ctrl+O / Ctrl+V / Drag & Drop</div>
</div>
<!-- Text overlay — contenteditable inline editor -->
<div class="text-overlay" id="textOverlay">
<div id="textArea" class="text-editor" contenteditable="true" spellcheck="false" role="textbox" aria-multiline="true"></div>
</div>
<!-- Layer Panel -->
<div class="layer-panel" id="layerPanel">
<div class="layer-panel-header">
<span>Слои</span>
<div class="layer-panel-actions">
<button class="lpa-btn" id="lpAdd" title="Новый слой (Ctrl+Shift+N)">+</button>
<button class="lpa-btn" id="lpDel" title="Удалить слой">&times;</button>
<button class="lpa-btn" id="lpMerge" title="Объединить вниз (Ctrl+E)">&darr;</button>
<button class="lpa-btn" id="lpFlatten" title="Свести всё (Ctrl+Shift+E)">F</button>
</div>
</div>
<div class="layer-list" id="layerList"></div>
<div class="layer-props" id="layerProps">
<div class="lp-row">
<label>Режим</label>
<select id="lpBlend">
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="darken">Darken</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
<option value="color-burn">Color Burn</option>
<option value="hard-light">Hard Light</option>
<option value="soft-light">Soft Light</option>
<option value="difference">Difference</option>
<option value="exclusion">Exclusion</option>
<option value="hue">Hue</option>
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
</select>
</div>
<div class="lp-row">
<label>Прозр.</label>
<input type="range" id="lpOpacity" min="0" max="100" value="100">
<span id="lpOpacityVal">100%</span>
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="statusbar">
<span id="statusDims">Нет изображения</span>
<span id="statusZoom">100%</span>
<span id="statusCursor">0, 0</span>
<span id="statusHistory"></span>
<span id="statusLayer"></span>
</div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="exportModal">
<div class="modal">
<h3>Экспорт</h3>
<div class="modal-row">
<label>Имя файла</label>
<input type="text" id="exportName" value="image">
</div>
<div class="modal-row">
<label>Формат</label>
<select id="exportFormat">
<option value="png">PNG</option>
<option value="jpeg">JPEG</option>
<option value="webp">WebP</option>
</select>
</div>
<div class="modal-row" id="qualityRow">
<label>Качество</label>
<input type="range" id="exportQuality" min="1" max="100" value="92">
<span id="exportQualityVal" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);width:30px;text-align:right">92</span>
</div>
<div class="modal-warning" id="exportWarning">Изображение содержит прозрачность. JPEG заменит её белым фоном.</div>
<div class="modal-actions">
<button id="exportCancel">Отмена</button>
<button id="exportConfirm" class="primary">Скачать</button>
</div>
</div>
</div>
<!-- Context Menu -->
<div class="ctx-menu" id="ctxMenu">
<div class="ctx-item" data-action="undo"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/></svg>Отменить<span class="ctx-shortcut">Ctrl+Z</span></div>
<div class="ctx-item" data-action="redo"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/></svg>Повторить<span class="ctx-shortcut">Ctrl+Shift+Z</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="crop-sel"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5m0 9V18A2.25 2.25 0 0118 20.25h-1.5m-9 0H6A2.25 2.25 0 013.75 18v-1.5"/></svg>Обрезать по выделению</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="resize"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>Изменить размер...</div>
<div class="ctx-item" data-action="rot-cw"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/></svg>Повернуть 90° вправо</div>
<div class="ctx-item" data-action="rot-ccw"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/></svg>Повернуть 90° влево</div>
<div class="ctx-item" data-action="flip-h"><svg 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 class="ctx-item" data-action="flip-v"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"/></svg>Отразить по вертикали</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="fit"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m9 0h4.5v4.5m0 9v4.5h-4.5m-9 0H3.75v-4.5"/></svg>Вписать в экран<span class="ctx-shortcut">Ctrl+0</span></div>
<div class="ctx-item" data-action="zoom100"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><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.607zM10.5 7.5v6m3-3h-6"/></svg>Масштаб 100%<span class="ctx-shortcut">Ctrl+1</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="copy"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"/></svg>Копировать в буфер</div>
<div class="ctx-item" data-action="paste"><svg 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.25m3.75 9v6m3-3H9m1.5-12H5.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>Вставить из буфера<span class="ctx-shortcut">Ctrl+V</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="export"><svg 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.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>Экспорт...<span class="ctx-shortcut">Ctrl+S</span></div>
</div>
<!-- Hidden file input -->
<input type="file" id="fileInput" accept="image/*" style="display:none">
<script src="/editor.js" defer></script>
</body>
</html>

3601
public/editor.js Normal file

File diff suppressed because it is too large Load Diff

260
public/formatter.html Normal file
View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Formatter — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root{--bg:#f5f5f7;--bg-grad1:rgba(0,67,184,.04);--bg-grad2:rgba(0,150,60,.03);--surface-800:rgba(255,255,255,0.85);--surface-700:#f0f0f3;--surface-600:#dde0e6;--text-primary:#1a1a2e;--text-secondary:#555;--text-muted:#888;--select-bg:#f0f0f3;--select-color:#1a1a2e}
.dark{--bg:#060c18;--bg-grad1:rgba(0,84,230,.03);--bg-grad2:rgba(0,67,184,.02);--surface-800:rgba(9,16,32,0.8);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--select-bg:#091020;--select-color:#e0e0e0}
body{background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,var(--bg-grad1) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,var(--bg-grad2) 0%,transparent 50%);min-height:100vh;transition:background .3s}
.dark .noise::before{content:'';position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");pointer-events:none;z-index:0}
.theme-toggle{position:relative;width:44px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s}
.dark .theme-toggle{background:#162040}.theme-toggle{background:#dde0e6}
.theme-toggle-knob{position:absolute;top:2px;width:20px;height:20px;border-radius:50%;transition:left .3s,background .3s;display:flex;align-items:center;justify-content:center}
.dark .theme-toggle-knob{left:22px;background:#0d1828}.theme-toggle-knob{left:2px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></a>
<a href="/compress" class="sidebar-link" title="Картинки"><svg width="22" height="22" 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></a>
<a href="/md" class="sidebar-link" title="Markdown"><svg width="22" height="22" 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></a>
<a href="/placeholder" class="sidebar-link" title="Placeholder"><svg width="22" height="22" 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></a>
<a href="/parser" class="sidebar-link" title="Parser"><svg width="22" height="22" 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></a>
<a href="/password" class="sidebar-link" title="Password"><svg width="22" height="22" 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></a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer"><svg width="22" height="22" 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></a>
<a href="/converter" class="sidebar-link" title="Converter"><svg width="22" height="22" 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></a>
<a href="/formatter" class="sidebar-link active" title="Formatter"><svg width="22" height="22" 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></a>
<a href="/editor" class="sidebar-link" title="Editor"><svg width="22" height="22" 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></a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-3xl mx-auto px-4 py-8 sm:py-12">
<div class="text-center mb-8 fade-up">
<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>
CSS &middot; JavaScript
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Code <span class="text-accent">Formatter</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">форматирование и beautify кода</p>
</div>
<!-- Tool Toggle -->
<div class="flex gap-2 mb-5 fade-up-delay">
<button class="tool-btn active flex-1 px-4 py-3 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="css">
<div class="text-sm font-semibold">CSS</div>
</button>
<button class="tool-btn flex-1 px-4 py-3 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="js">
<div class="text-sm font-semibold">JavaScript</div>
</button>
</div>
<!-- Processing 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="mb-4">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Input</label>
<textarea id="inputArea" rows="10" placeholder="Вставьте CSS..."
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none focus:border-accent/50 transition-colors"></textarea>
</div>
<button id="processBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
Форматировать
</button>
<div id="outputBlock" class="mt-5 hidden">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium dark:text-gray-500 text-gray-400 font-mono uppercase tracking-wider">Output</label>
<div class="flex items-center gap-2">
<span id="stats" class="text-[10px] font-mono dark:text-gray-600 text-gray-400"></span>
<button id="copyBtn" class="px-3 py-1 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">Копировать</button>
<button id="clearBtn" class="px-3 py-1 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-400 text-gray-500 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">Очистить</button>
</div>
</div>
<textarea id="outputArea" rows="14" readonly
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none transition-colors"></textarea>
</div>
</div>
</div>
</div>
<script>
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
let activeTool = 'css';
const $ = id => document.getElementById(id);
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeTool = btn.dataset.tool;
$('inputArea').placeholder = activeTool === 'css' ? 'Вставьте CSS...' : 'Вставьте JavaScript...';
});
});
$('processBtn').addEventListener('click', () => {
const input = $('inputArea').value;
if (!input.trim()) return;
let output = '';
try {
output = activeTool === 'css' ? formatCss(input) : formatJs(input);
} catch (e) { output = 'Error: ' + e.message; }
$('outputArea').value = output;
$('outputBlock').classList.remove('hidden');
const diff = output.length - input.length;
$('stats').textContent = `${input.length} \u2192 ${output.length} (${diff > 0 ? '+' : ''}${diff})`;
});
$('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText($('outputArea').value).then(() => {
const b = $('copyBtn'); const o = b.textContent;
b.textContent = '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!'; setTimeout(() => { b.textContent = o; }, 1500);
});
});
$('clearBtn').addEventListener('click', () => {
$('inputArea').value = ''; $('outputArea').value = ''; $('outputBlock').classList.add('hidden');
});
// ==================== CSS Formatter ====================
function formatCss(input) {
const preserved = [];
const hold = m => { preserved.push(m); return '\x00' + (preserved.length - 1) + '\x00'; };
let css = input;
css = css.replace(/(["'])(?:(?!\1|\\).|\\.)*\1/g, hold);
css = css.replace(/\/\*[\s\S]*?\*\//g, hold);
css = css.replace(/\s+/g, ' ').trim();
css = css.replace(/\s*\{\s*/g, ' {\n');
css = css.replace(/\s*\}\s*/g, '\n}\n\n');
css = css.replace(/;\s*/g, ';\n');
let indent = 0;
const lines = css.split('\n');
const formatted = [];
for (const raw of lines) {
const line = raw.trim();
if (!line) { if (formatted.length && formatted[formatted.length - 1] !== '') formatted.push(''); continue; }
if (line.startsWith('}')) indent = Math.max(0, indent - 1);
formatted.push(' '.repeat(indent) + line);
if (line.endsWith('{')) indent++;
}
let result = formatted.join('\n').replace(/\n{3,}/g, '\n\n').trim();
result = result.replace(/\x00(\d+)\x00/g, (_, i) => preserved[parseInt(i)]);
return result;
}
// ==================== JS Formatter ====================
function formatJs(input) {
const preserved = [];
const hold = m => { preserved.push(m); return '\x00' + (preserved.length - 1) + '\x00'; };
let js = input;
js = js.replace(/`(?:[^`\\]|\\.)*`/gs, hold);
js = js.replace(/(["'])(?:(?!\1|\\).|\\.)*\1/g, hold);
js = js.replace(/\/\*[\s\S]*?\*\//g, hold);
js = js.replace(/\/\/[^\n]*/g, hold);
js = js.replace(/\s+/g, ' ').trim();
js = js.replace(/\s*\{\s*/g, ' {\n');
js = js.replace(/\s*\}\s*/g, '\n}\n');
js = js.replace(/;\s*/g, ';\n');
let indent = 0;
const lines = js.split('\n');
const formatted = [];
for (const raw of lines) {
const line = raw.trim();
if (!line) { if (formatted.length && formatted[formatted.length - 1] !== '') formatted.push(''); continue; }
if (line.startsWith('}')) indent = Math.max(0, indent - 1);
formatted.push(' '.repeat(indent) + line);
if (line.endsWith('{')) indent++;
}
let result = formatted.join('\n').replace(/\n{3,}/g, '\n\n').trim();
result = result.replace(/\x00(\d+)\x00/g, (_, i) => preserved[parseInt(i)]);
return result;
}
</script>
</body>
</html>

813
public/httpclient.html Normal file
View File

@ -0,0 +1,813 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTTP Client — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link rel="stylesheet" href="/vendor/hljs-github-dark.css">
<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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root{--bg:#f5f5f7;--bg-grad1:rgba(0,67,184,.04);--bg-grad2:rgba(0,50,140,.03);--surface-800:rgba(255,255,255,0.85);--surface-700:#f0f0f3;--surface-600:#dde0e6;--text-primary:#1a1a2e;--text-secondary:#555;--text-muted:#888;--select-bg:#fff;--select-color:#1a1a2e}
.dark{--bg:#060c18;--bg-grad1:rgba(0,84,230,.03);--bg-grad2:rgba(0,67,184,.02);--surface-800:rgba(9,16,32,0.8);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--select-bg:#091020;--select-color:#e0e0e0}
body{background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,var(--bg-grad1) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,var(--bg-grad2) 0%,transparent 50%);min-height:100vh;transition:background .3s}
.dark .noise::before{content:'';position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");pointer-events:none;z-index:0}
.theme-toggle{position:relative;width:44px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s}
.dark .theme-toggle{background:#162040}
.theme-toggle{background:#dde0e6}
.theme-toggle-knob{position:absolute;top:2px;width:20px;height:20px;border-radius:50%;transition:left .3s,background .3s;display:flex;align-items:center;justify-content:center}
.dark .theme-toggle-knob{left:22px;background:#0d1828}
.theme-toggle-knob{left:2px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
/* HTTP Client UI */
.panel {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
overflow: hidden;
}
.tab-btn {
padding: 6px 14px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
transition: all .2s;
white-space: nowrap;
}
.tab-btn:hover { color: var(--text-secondary); background: rgba(0,84,230,.05); }
.tab-btn.active { color: #0054e6; background: rgba(0,84,230,.08); }
.kv-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
.kv-row input {
flex: 1;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
min-width: 0;
}
.kv-row input:focus { border-color: rgba(0,84,230,.5); }
.kv-row input::placeholder { color: var(--text-muted); }
.kv-del {
width: 24px; height: 24px;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
color: var(--text-muted);
transition: all .15s;
flex-shrink: 0;
}
.kv-del:hover { color: #ff5252; background: rgba(255,82,82,.1); }
.kv-add {
font-size: 11px; font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
cursor: pointer;
padding: 4px 0;
transition: color .15s;
}
.kv-add:hover { color: #0054e6; }
.method-badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 4px;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
}
.m-GET { background: rgba(0,168,107,.15); color: #00a86b; }
.m-POST { background: rgba(255,165,0,.15); color: #ffa500; }
.m-PUT { background: rgba(0,84,230,.15); color: #6b9fff; }
.m-PATCH { background: rgba(148,0,211,.15); color: #cc66ff; }
.m-DELETE { background: rgba(255,82,82,.15); color: #ff5252; }
.m-HEAD { background: rgba(150,150,150,.15); color: #aaa; }
.m-OPTIONS{ background: rgba(150,150,150,.15); color: #aaa; }
.status-2xx { color: #00a86b; }
.status-3xx { color: #6b9fff; }
.status-4xx { color: #ffa500; }
.status-5xx { color: #ff5252; }
.send-btn {
background: #0054e6;
color: #fff;
border: none;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex; align-items: center; gap: 6px;
transition: background .2s;
white-space: nowrap;
height: 40px;
}
.send-btn:hover { background: #0043b8; }
.send-btn:disabled { background: #0054e680; cursor: not-allowed; }
.url-input {
flex: 1;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 0 12px;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
height: 40px;
}
.url-input:focus { border-color: rgba(0,84,230,.5); }
.url-input::placeholder { color: var(--text-muted); }
select.method-select {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 0 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: var(--select-color);
outline: none;
cursor: pointer;
height: 40px;
}
select.method-select:focus { border-color: rgba(0,84,230,.5); }
.response-body-pre {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
padding: 12px;
margin: 0;
min-height: 200px;
overflow: auto;
color: var(--text-secondary);
}
.hljs { background: transparent !important; padding: 0 !important; }
.history-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
}
.history-item:hover { background: rgba(255,255,255,.04); }
.history-url {
flex: 1;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-meta {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
}
.body-type-select {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
cursor: pointer;
}
textarea.body-area {
width: 100%;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
resize: vertical;
min-height: 160px;
line-height: 1.6;
transition: border-color .2s;
}
textarea.body-area:focus { border-color: rgba(0,84,230,.5); }
textarea.body-area::placeholder { color: var(--text-muted); }
.spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.panels-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media(max-width: 900px) {
.panels-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link active" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<div class="mb-8 text-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>
GET · POST · PUT · DELETE
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
HTTP <span class="text-accent">Клиент</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">тест API запросов</p>
</div>
<!-- URL Bar -->
<div class="flex gap-2 mb-4">
<select id="method" class="method-select">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
<option value="HEAD">HEAD</option>
<option value="OPTIONS">OPTIONS</option>
</select>
<input id="urlInput" type="text" class="url-input" placeholder="https://api.example.com/endpoint" autocomplete="off" spellcheck="false" />
<button id="sendBtn" class="send-btn">
<svg width="15" height="15" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><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>
Отправить
</button>
</div>
<!-- Panels -->
<div class="panels-grid mb-4">
<!-- REQUEST PANEL -->
<div class="panel">
<div class="flex border-b border-surface-600">
<button class="tab-btn active" data-reqtab="params">Параметры</button>
<button class="tab-btn" data-reqtab="headers">Заголовки</button>
<button class="tab-btn" data-reqtab="body">Тело</button>
<button class="tab-btn" data-reqtab="auth">Авторизация</button>
</div>
<!-- Params tab -->
<div id="reqTab-params" class="p-3">
<div id="params-rows"></div>
<div class="kv-add" id="params-add">+ Добавить параметр</div>
</div>
<!-- Headers tab -->
<div id="reqTab-headers" class="p-3 hidden">
<div id="headers-rows"></div>
<div class="kv-add" id="headers-add">+ Добавить заголовок</div>
</div>
<!-- Body tab -->
<div id="reqTab-body" class="p-3 hidden">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono text-muted">Тип:</span>
<select id="bodyType" class="body-type-select">
<option value="none">нет</option>
<option value="json">JSON</option>
<option value="form">form-urlencoded</option>
<option value="raw">текст</option>
</select>
</div>
<div id="body-none" class="text-xs font-mono text-muted py-4 text-center">Тело запроса не отправляется</div>
<div id="body-json" class="hidden">
<textarea id="bodyJson" class="body-area" placeholder='{"key": "value"}'></textarea>
</div>
<div id="body-form" class="hidden">
<div id="form-rows"></div>
<div class="kv-add" id="form-add">+ Добавить поле</div>
</div>
<div id="body-raw" class="hidden">
<textarea id="bodyRaw" class="body-area" placeholder="Raw request body..."></textarea>
</div>
</div>
<!-- Auth tab -->
<div id="reqTab-auth" class="p-3 hidden">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono text-muted">Тип:</span>
<select id="authType" class="body-type-select">
<option value="none">нет</option>
<option value="bearer">Bearer Токен</option>
<option value="basic">Basic Auth</option>
</select>
</div>
<div id="auth-none" class="text-xs font-mono text-muted py-4 text-center">Без авторизации</div>
<div id="auth-bearer" class="hidden">
<input id="bearerToken" type="text" class="kv-row" style="width:100%;display:block;margin:0;border-radius:8px;padding:8px 12px;background:var(--select-bg);border:1px solid var(--surface-600);font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--select-color);outline:none" placeholder="Токен..." />
</div>
<div id="auth-basic" class="hidden">
<input id="basicUser" type="text" style="width:100%;display:block;margin-bottom:8px;border-radius:8px;padding:8px 12px;background:var(--select-bg);border:1px solid var(--surface-600);font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--select-color);outline:none" placeholder="Логин" />
<input id="basicPass" type="password" style="width:100%;display:block;border-radius:8px;padding:8px 12px;background:var(--select-bg);border:1px solid var(--surface-600);font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--select-color);outline:none" placeholder="Пароль" />
</div>
</div>
</div>
<!-- RESPONSE PANEL -->
<div class="panel">
<!-- Status bar -->
<div id="responseStatusBar" class="flex items-center gap-3 px-4 py-2 border-b border-surface-600 min-h-[41px]">
<span class="text-xs font-mono text-muted">Нажмите «Отправить» для запроса</span>
</div>
<!-- Tabs -->
<div class="flex border-b border-surface-600">
<button class="tab-btn active" data-restab="body">Тело</button>
<button class="tab-btn" data-restab="headers">Заголовки</button>
</div>
<!-- Body -->
<div id="resTab-body">
<div id="responseBodyWrap" style="max-height:480px;overflow:auto;position:relative">
<div id="responseBodyEmpty" class="flex flex-col items-center justify-center py-16 text-center">
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1" class="mb-3 opacity-20"><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>
<span class="text-xs font-mono text-muted">Ответ появится здесь</span>
</div>
<pre id="responseBodyPre" class="response-body-pre hidden"><code id="responseBodyCode"></code></pre>
</div>
<div id="responseCopyBar" class="hidden flex items-center justify-between px-3 py-1.5 border-t border-surface-600">
<span id="responseSizeLabel" class="text-xs font-mono text-muted"></span>
<button id="copyBodyBtn" class="text-xs font-mono text-muted hover:text-accent transition-colors">Копировать</button>
</div>
</div>
<!-- Response Headers -->
<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>
</div>
</div>
<!-- History -->
<div class="panel">
<div class="flex items-center justify-between px-4 py-2 border-b border-surface-600 cursor-pointer select-none" id="historyToggle">
<span class="text-xs font-mono font-semibold" style="color:var(--text-secondary)">История запросов</span>
<div class="flex items-center gap-3">
<button id="clearHistoryBtn" class="text-xs font-mono text-muted hover:text-danger transition-colors hidden">Очистить</button>
<svg id="historyArrow" width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" style="color:var(--text-muted);transition:transform .2s"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
</div>
</div>
<div id="historyList" class="px-2 py-1" style="max-height:260px;overflow-y:auto"></div>
</div>
</div>
</div><!-- /main-content -->
<script src="/vendor/highlight.min.js"></script>
<script>
(() => {
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) html.className = savedTheme;
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) html.className = '';
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
// State
let reqTabs = {}, resTabs = {};
let isLoading = false;
// KV row builder
function makeKvRow(container, keyVal, valVal) {
const row = document.createElement('div');
row.className = 'kv-row';
const k = document.createElement('input');
k.type = 'text'; k.placeholder = 'Ключ'; k.value = keyVal || '';
k.className = 'kv-input-key';
const v = document.createElement('input');
v.type = 'text'; v.placeholder = 'Значение'; v.value = valVal || '';
v.className = 'kv-input-val';
const del = document.createElement('div');
del.className = 'kv-del';
del.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>';
del.onclick = () => row.remove();
row.appendChild(k); row.appendChild(v); row.appendChild(del);
container.appendChild(row);
return row;
}
function getKvRows(container) {
const result = {};
container.querySelectorAll('.kv-row').forEach(row => {
const k = row.querySelector('.kv-input-key')?.value?.trim();
const v = row.querySelector('.kv-input-val')?.value?.trim();
if (k) result[k] = v || '';
});
return result;
}
// Init KV containers
const paramsRows = document.getElementById('params-rows');
const headersRows = document.getElementById('headers-rows');
const formRows = document.getElementById('form-rows');
makeKvRow(paramsRows, '', '');
makeKvRow(headersRows, 'Content-Type', 'application/json');
makeKvRow(formRows, '', '');
document.getElementById('params-add').onclick = () => makeKvRow(paramsRows, '', '');
document.getElementById('headers-add').onclick = () => makeKvRow(headersRows, '', '');
document.getElementById('form-add').onclick = () => makeKvRow(formRows, '', '');
// Request tabs
document.querySelectorAll('[data-reqtab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-reqtab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.reqtab;
['params','headers','body','auth'].forEach(t => {
document.getElementById('reqTab-' + t).classList.toggle('hidden', t !== tab);
});
});
});
// Response tabs
document.querySelectorAll('[data-restab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-restab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.restab;
['body','headers'].forEach(t => {
document.getElementById('resTab-' + t).classList.toggle('hidden', t !== tab);
});
});
});
// Body type
document.getElementById('bodyType').addEventListener('change', function() {
['none','json','form','raw'].forEach(t => {
document.getElementById('body-' + t).classList.toggle('hidden', t !== this.value);
});
});
// Auth type
document.getElementById('authType').addEventListener('change', function() {
['none','bearer','basic'].forEach(t => {
document.getElementById('auth-' + t).classList.toggle('hidden', t !== this.value);
});
});
// Build request config
function buildRequest() {
const method = document.getElementById('method').value;
let url = document.getElementById('urlInput').value.trim();
if (!url) return null;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
document.getElementById('urlInput').value = url;
}
// Params → append to URL
const params = getKvRows(paramsRows);
if (Object.keys(params).length > 0) {
try {
const u = new URL(url);
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
url = u.toString();
} catch {}
}
// Headers
const headers = getKvRows(headersRows);
// Auth
const authType = document.getElementById('authType').value;
if (authType === 'bearer') {
const tok = document.getElementById('bearerToken').value.trim();
if (tok) headers['Authorization'] = 'Bearer ' + tok;
} else if (authType === 'basic') {
const u = document.getElementById('basicUser').value;
const p = document.getElementById('basicPass').value;
headers['Authorization'] = 'Basic ' + btoa(u + ':' + p);
}
// Body
let body = null;
const bodyType = document.getElementById('bodyType').value;
if (!['GET','HEAD'].includes(method)) {
if (bodyType === 'json') {
body = document.getElementById('bodyJson').value.trim();
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
} else if (bodyType === 'form') {
const formData = getKvRows(formRows);
body = new URLSearchParams(formData).toString();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else if (bodyType === 'raw') {
body = document.getElementById('bodyRaw').value;
}
}
return { method, url, headers, body };
}
// Send
async function sendRequest() {
if (isLoading) return;
const req = buildRequest();
if (!req) {
document.getElementById('urlInput').focus();
return;
}
isLoading = true;
const sendBtn = document.getElementById('sendBtn');
sendBtn.disabled = true;
sendBtn.innerHTML = '<div class="spinner"></div> Отправка...';
// Status bar: loading
const statusBar = document.getElementById('responseStatusBar');
statusBar.innerHTML = '<span class="text-xs font-mono text-muted">Отправка запроса...</span>';
// Hide response
document.getElementById('responseBodyEmpty').classList.remove('hidden');
document.getElementById('responseBodyPre').classList.add('hidden');
document.getElementById('responseCopyBar').classList.add('hidden');
document.getElementById('responseHeadersContent').innerHTML = '';
try {
const t0 = performance.now();
const resp = await fetch('/api/proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
const data = await resp.json();
const elapsed = Math.round(performance.now() - t0);
if (data.error && !data.status) {
// Network/timeout error
statusBar.innerHTML = `<span class="text-xs font-mono" style="color:#ff5252">Ошибка: ${escHtml(data.error)}</span><span class="text-xs font-mono text-muted ml-auto">${data.time || elapsed}мс</span>`;
showBody('❌ ' + data.error, 'plaintext');
addToHistory(req.method, req.url, null, data.time || elapsed);
return;
}
// Status badge
const statusClass = data.status >= 500 ? 'status-5xx' : data.status >= 400 ? 'status-4xx' : data.status >= 300 ? 'status-3xx' : 'status-2xx';
const sizeStr = formatSize(data.size);
statusBar.innerHTML = `
<span class="text-sm font-mono font-bold ${statusClass}">${data.status} ${escHtml(data.statusText || '')}</span>
<span class="text-xs font-mono text-muted">${data.time}мс</span>
<span class="text-xs font-mono text-muted">${sizeStr}</span>
${data.truncated ? '<span class="text-xs font-mono" style="color:#ffa500">⚠ обрезано до 10MB</span>' : ''}
${data.url !== req.url ? '<span class="text-xs font-mono text-muted ml-auto" title="' + escHtml(data.url) + '">→ redirect</span>' : ''}
`;
// Response body
const ct = (data.headers?.['content-type'] || '').toLowerCase();
let lang = 'plaintext';
let bodyText = data.body || '';
if (ct.includes('json')) {
lang = 'json';
try { bodyText = JSON.stringify(JSON.parse(bodyText), null, 2); } catch {}
} else if (ct.includes('html')) {
lang = 'xml';
} else if (ct.includes('xml')) {
lang = 'xml';
} else if (ct.includes('javascript')) {
lang = 'javascript';
}
showBody(bodyText, lang);
// Response headers
const hContent = document.getElementById('responseHeadersContent');
hContent.innerHTML = '';
if (data.headers) {
Object.entries(data.headers).sort().forEach(([k, v]) => {
const row = document.createElement('div');
row.className = 'flex gap-2 py-0.5';
row.innerHTML = `<span style="color:#6b9fff;min-width:160px">${escHtml(k)}</span><span style="color:var(--text-secondary)">${escHtml(v)}</span>`;
hContent.appendChild(row);
});
}
// Copy button
const copyBar = document.getElementById('responseCopyBar');
copyBar.classList.remove('hidden');
document.getElementById('responseSizeLabel').textContent = sizeStr;
document.getElementById('copyBodyBtn').onclick = () => {
navigator.clipboard.writeText(data.body || '').then(() => {
document.getElementById('copyBodyBtn').textContent = 'Скопировано!';
setTimeout(() => document.getElementById('copyBodyBtn').textContent = 'Копировать', 1500);
});
};
addToHistory(req.method, req.url, data.status, data.time);
} catch (err) {
statusBar.innerHTML = `<span class="text-xs font-mono" style="color:#ff5252">Ошибка соединения: ${escHtml(err.message)}</span>`;
showBody('Ошибка: ' + err.message, 'plaintext');
} finally {
isLoading = false;
sendBtn.disabled = false;
sendBtn.innerHTML = '<svg width="15" height="15" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><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> Отправить';
}
}
function showBody(text, lang) {
const empty = document.getElementById('responseBodyEmpty');
const pre = document.getElementById('responseBodyPre');
const code = document.getElementById('responseBodyCode');
empty.classList.add('hidden');
pre.classList.remove('hidden');
code.textContent = text;
code.className = 'language-' + lang;
if (window.hljs && lang !== 'plaintext') {
hljs.highlightElement(code);
}
}
function formatSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/(1024*1024)).toFixed(1) + ' MB';
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Send button + Enter
document.getElementById('sendBtn').onclick = sendRequest;
document.getElementById('urlInput').addEventListener('keydown', e => {
if (e.key === 'Enter') sendRequest();
});
// History (server-side, per session)
const methodColors = { GET:'#00a86b', POST:'#ffa500', PUT:'#6b9fff', PATCH:'#cc66ff', DELETE:'#ff5252', HEAD:'#aaa', OPTIONS:'#aaa' };
async function addToHistory(method, url, status, time) {
await fetch('/api/history/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method, url, status, time, ts: Date.now() }),
}).catch(() => {});
renderHistory();
}
async function renderHistory() {
const list = document.getElementById('historyList');
const clearBtn = document.getElementById('clearHistoryBtn');
let h = [];
try {
const r = await fetch('/api/history');
h = await r.json();
} catch {}
if (!Array.isArray(h) || h.length === 0) {
list.innerHTML = '<div class="text-xs font-mono text-muted py-4 text-center">История пуста</div>';
clearBtn.classList.add('hidden');
return;
}
clearBtn.classList.remove('hidden');
list.innerHTML = '';
h.forEach((item) => {
const div = document.createElement('div');
div.className = 'history-item';
const statusClass = !item.status ? 'text-muted' : item.status >= 500 ? 'status-5xx' : item.status >= 400 ? 'status-4xx' : item.status >= 300 ? 'status-3xx' : 'status-2xx';
const statusText = item.status ? item.status : '—';
div.innerHTML = `
<span class="method-badge m-${item.method}" style="min-width:56px;justify-content:center">${item.method}</span>
<span class="history-url">${escHtml(item.url)}</span>
<span class="history-meta ${statusClass}">${statusText}</span>
<span class="history-meta">${item.time}мс</span>
`;
div.onclick = () => loadHistoryItem(item);
list.appendChild(div);
});
}
function loadHistoryItem(item) {
document.getElementById('method').value = item.method;
document.getElementById('urlInput').value = item.url;
}
document.getElementById('clearHistoryBtn').onclick = async (e) => {
e.stopPropagation();
await fetch('/api/history', { method: 'DELETE' }).catch(() => {});
renderHistory();
};
// History toggle
let historyOpen = true;
document.getElementById('historyToggle').onclick = () => {
historyOpen = !historyOpen;
document.getElementById('historyList').style.display = historyOpen ? '' : 'none';
document.getElementById('historyArrow').style.transform = historyOpen ? '' : 'rotate(-90deg)';
};
renderHistory();
})();
</script>
</body>
</html>

355
public/index.html Normal file
View File

@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — Инструменты</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
/* Mobile bottom bar */
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
/* Theme variables */
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
--bar-bg: #e0e0e0;
--select-bg: #f0f0f3;
--select-color: #1a1a2e;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
--bar-bg: #0d1828;
--select-bg: #091020;
--select-color: #e0e0e0;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
/* Theme toggle */
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
/* Dashboard tiles */
.tool-tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
border-radius: 16px;
border: 1px solid var(--surface-600);
background: var(--surface-700);
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,.3);
background: var(--surface-800);
}
.tile-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(0,84,230,.1);
display: flex;
align-items: center;
justify-content: center;
color: #0054e6;
transition: background .2s;
}
.tool-tile:hover .tile-icon { background: rgba(0,84,230,.15); }
.tile-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.tile-desc { font-size: 12px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link active" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-5xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="mb-10 fade-up">
<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>
images.wadevelop.ru
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
WA Dev <span class="text-accent">Tools</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Инструменты разработчика</p>
</div>
<!-- Tools Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 fade-up-delay">
<!-- Сжать картинки -->
<a href="/compress" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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">JPEG, PNG, WebP до 20МБ</div>
</div>
</a>
<!-- Markdown -->
<a href="/md" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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</div>
<div class="tile-desc">Просмотр Markdown файлов</div>
</div>
</a>
<!-- Placeholder -->
<a href="/placeholder" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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>
<!-- URL Parser -->
<a href="/parser" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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">URL Parser</div>
<div class="tile-desc">Извлечение контента из URL</div>
</div>
</a>
<!-- Пароли -->
<a href="/password" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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>
<!-- Sanitizer -->
<a href="/sanitizer" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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">Sanitizer</div>
<div class="tile-desc">Очистка HTML от мусора</div>
</div>
</a>
<!-- Конвертер -->
<a href="/converter" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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">Конвертация форматов данных</div>
</div>
</a>
<!-- Форматтер -->
<a href="/formatter" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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">Форматирование кода</div>
</div>
</a>
<!-- Редактор -->
<a href="/editor" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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>
<!-- HTTP Client -->
<a href="/httpclient" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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 Client</div>
<div class="tile-desc">Тест API запросов — Postman lite</div>
</div>
</a>
<!-- Redirect Analyzer -->
<a href="/redirects" class="tool-tile group">
<div class="tile-icon">
<svg width="22" height="22" 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 Analyzer</div>
<div class="tile-desc">Анализ цепочки HTTP-редиректов</div>
</div>
</a>
</div>
</div>
</div><!-- /main-content -->
<script>
// Theme toggle
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
</script>
</body>
</html>

401
public/md.html Normal file
View File

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Viewer</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<script src="/vendor/marked.min.js"></script>
<script src="/vendor/highlight.min.js"></script>
<link id="hljsTheme" href="/vendor/hljs-github-dark.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
/* Theme variables */
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
/* Theme toggle */
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
/* Dropzone */
.md-dropzone { border: 2px dashed var(--surface-600); transition: all .3s; }
.md-dropzone.active { border-color: #0054e6; background: rgba(0,84,230,.05); }
/* Markdown rendered output */
.md-output {
color: var(--text-primary);
line-height: 1.75;
font-size: 15px;
}
.md-output h1 { font-size: 2em; font-weight: 800; margin: 1.2em 0 .6em; padding-bottom: .3em; border-bottom: 1px solid var(--surface-600); }
.md-output h2 { font-size: 1.5em; font-weight: 700; margin: 1em 0 .5em; padding-bottom: .25em; border-bottom: 1px solid var(--surface-600); }
.md-output h3 { font-size: 1.25em; font-weight: 600; margin: .9em 0 .4em; }
.md-output h4 { font-size: 1.1em; font-weight: 600; margin: .8em 0 .3em; }
.md-output p { margin: .6em 0; }
.md-output ul, .md-output ol { margin: .6em 0; padding-left: 1.8em; }
.md-output li { margin: .25em 0; }
.md-output ul li { list-style: disc; }
.md-output ol li { list-style: decimal; }
.md-output a { color: #0054e6; text-decoration: underline; text-underline-offset: 2px; }
.md-output blockquote { border-left: 3px solid #0054e6; padding: .5em 1em; margin: .8em 0; color: var(--text-secondary); background: rgba(0,84,230,.03); border-radius: 0 8px 8px 0; }
.md-output code { font-family: 'JetBrains Mono', monospace; font-size: .88em; padding: .15em .4em; border-radius: 4px; background: var(--surface-700); }
.md-output pre { margin: .8em 0; border-radius: 10px; overflow-x: auto; }
.md-output pre code { display: block; padding: 1em 1.2em; background: var(--surface-700); font-size: .85em; line-height: 1.6; border-radius: 10px; }
.md-output table { border-collapse: collapse; width: 100%; margin: .8em 0; font-size: .92em; }
.md-output th, .md-output td { border: 1px solid var(--surface-600); padding: .5em .8em; text-align: left; }
.md-output th { background: var(--surface-700); font-weight: 600; }
.md-output hr { border: none; border-top: 1px solid var(--surface-600); margin: 1.5em 0; }
.md-output img { max-width: 100%; border-radius: 8px; margin: .8em 0; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
/* Tabs */
.tab-btn { transition: all .2s; }
.tab-btn.active { color: #0054e6; border-color: #0054e6; }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link active" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-4xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<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>
MD Viewer
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Просмотр <span class="text-accent">Markdown</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">drag-drop файл или вставьте текст</p>
</div>
<!-- Input 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">
<!-- Tabs -->
<div class="flex gap-2 mb-5 border-b dark:border-surface-600 border-gray-200 pb-3">
<button class="tab-btn active text-sm font-medium px-3 py-1.5 border-b-2 border-transparent dark:text-gray-400 text-gray-500" data-tab="file">
Файл
</button>
<button class="tab-btn text-sm font-medium px-3 py-1.5 border-b-2 border-transparent dark:text-gray-400 text-gray-500" data-tab="text">
Текст
</button>
</div>
<!-- File tab -->
<div id="tabFile" class="tab-content">
<div id="dropzone" class="md-dropzone rounded-xl p-8 sm:p-10 text-center cursor-pointer group">
<svg class="mx-auto w-10 h-10 dark:text-gray-500 text-gray-400 group-hover:text-accent transition-colors mb-3" 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>
<p class="text-sm dark:text-gray-400 text-gray-500">
Перетащите <span class="text-accent font-medium">.md</span> файл сюда или <span class="text-accent font-medium cursor-pointer">выберите</span>
</p>
<p id="fileName" class="mt-2 text-xs font-mono dark:text-gray-600 text-gray-400 hidden"></p>
<input type="file" accept=".md,.markdown,.txt" id="fileInput" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer">
</div>
</div>
<!-- Text tab -->
<div id="tabText" class="tab-content hidden">
<textarea id="mdInput" class="w-full h-48 px-4 py-3 rounded-xl text-sm font-mono dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none focus:border-accent/50 resize-y transition-colors" placeholder="Вставьте Markdown текст..."></textarea>
<button id="renderBtn" class="mt-3 px-5 py-2.5 rounded-xl text-sm font-semibold bg-accent text-white hover:bg-accent-bright transition-all active:scale-[0.98]">
Рендер
</button>
</div>
</div>
<!-- Output -->
<div id="outputCard" class="hidden mt-5 fade-up">
<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">
<!-- Toolbar -->
<div class="flex items-center justify-between mb-4 pb-3 border-b dark:border-surface-600 border-gray-200">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Результат</span>
<div class="flex gap-2">
<button id="downloadHtmlBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Скачать HTML
</button>
<button id="copyHtmlBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200">
Скопировать HTML
</button>
<button id="clearBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">
Очистить
</button>
</div>
</div>
<!-- Rendered markdown -->
<div id="mdOutput" class="md-output"></div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
function applyHljsTheme() {
const isDark = html.classList.contains('dark');
document.getElementById('hljsTheme').href = isDark ? '/vendor/hljs-github-dark.css' : '/vendor/hljs-github.css';
}
applyHljsTheme();
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
applyHljsTheme();
});
// Tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('tabFile').classList.toggle('hidden', tab !== 'file');
document.getElementById('tabText').classList.toggle('hidden', tab !== 'text');
});
});
// Marked config
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true,
});
function renderMarkdown(md) {
const output = document.getElementById('mdOutput');
const card = document.getElementById('outputCard');
output.innerHTML = marked.parse(md);
card.classList.remove('hidden');
// Re-highlight any code blocks
output.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
}
// File drop
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
// Make dropzone relative for absolute file input
dropzone.style.position = 'relative';
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('active'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('active'));
dropzone.addEventListener('drop', e => {
e.preventDefault();
dropzone.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file) readFile(file);
});
fileInput.addEventListener('change', e => {
if (e.target.files[0]) readFile(e.target.files[0]);
});
function readFile(file) {
const nameEl = document.getElementById('fileName');
nameEl.textContent = file.name;
nameEl.classList.remove('hidden');
const reader = new FileReader();
reader.onload = e => renderMarkdown(e.target.result);
reader.readAsText(file);
}
// Text render
document.getElementById('renderBtn').addEventListener('click', () => {
const text = document.getElementById('mdInput').value.trim();
if (text) renderMarkdown(text);
});
// Also render on Ctrl+Enter in textarea
document.getElementById('mdInput').addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
document.getElementById('renderBtn').click();
}
});
// Copy HTML
document.getElementById('copyHtmlBtn').addEventListener('click', () => {
const html = document.getElementById('mdOutput').innerHTML;
navigator.clipboard.writeText(html).then(() => {
const btn = document.getElementById('copyHtmlBtn');
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
btn.classList.add('text-accent');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('text-accent'); }, 1500);
});
});
// Download HTML
document.getElementById('downloadHtmlBtn').addEventListener('click', () => {
const body = document.getElementById('mdOutput').innerHTML;
const fullHtml = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n<title>Markdown</title>\n<style>\nbody{font-family:sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#333}\npre{background:#f5f5f5;padding:1em;border-radius:6px;overflow-x:auto}\ncode{font-family:monospace;font-size:.9em;background:#f0f0f0;padding:2px 5px;border-radius:3px}\npre code{background:none;padding:0}\ntable{border-collapse:collapse;width:100%}\nth,td{border:1px solid #ddd;padding:8px;text-align:left}\nth{background:#f5f5f5}\nblockquote{border-left:3px solid #0054e6;padding:.5em 1em;margin:1em 0;color:#666;background:#f9f9f9}\nimg{max-width:100%}\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>';
const blob = new Blob([fullHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'markdown.html';
a.click();
URL.revokeObjectURL(url);
});
// Clear
document.getElementById('clearBtn').addEventListener('click', () => {
document.getElementById('mdOutput').innerHTML = '';
document.getElementById('outputCard').classList.add('hidden');
document.getElementById('mdInput').value = '';
document.getElementById('fileName').classList.add('hidden');
fileInput.value = '';
});
</script>
</body>
</html>

564
public/parser.html Normal file
View File

@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>URL Parser — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
--select-bg: #f0f0f3;
--select-color: #1a1a2e;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
--select-bg: #091020;
--select-color: #e0e0e0;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
@keyframes pulse-border { 0%, 100% { border-color: rgba(0, 84, 230, 0.2); } 50% { border-color: rgba(0, 84, 230, 0.5); } }
.processing { animation: pulse-border 1.5s ease-in-out infinite; }
.article-content { color: var(--text-primary); line-height: 1.75; font-size: 15px; }
.article-content h1 { font-size: 1.8em; font-weight: 800; margin: 1em 0 .5em; }
.article-content h2 { font-size: 1.4em; font-weight: 700; margin: .9em 0 .4em; }
.article-content h3 { font-size: 1.2em; font-weight: 600; margin: .8em 0 .3em; }
.article-content p { margin: .6em 0; }
.article-content ul, .article-content ol { margin: .6em 0; padding-left: 1.8em; }
.article-content li { margin: .25em 0; }
.article-content ul li { list-style: disc; }
.article-content ol li { list-style: decimal; }
.article-content a { color: #0054e6; text-decoration: underline; text-underline-offset: 2px; }
.article-content blockquote { border-left: 3px solid #0054e6; padding: .5em 1em; margin: .8em 0; color: var(--text-secondary); background: rgba(0,84,230,.03); border-radius: 0 8px 8px 0; }
.article-content code { font-family: 'JetBrains Mono', monospace; font-size: .88em; padding: .15em .4em; border-radius: 4px; background: var(--surface-700); }
.article-content pre { margin: .8em 0; border-radius: 10px; overflow-x: auto; }
.article-content pre code { display: block; padding: 1em 1.2em; background: var(--surface-700); }
.article-content img { max-width: 100%; border-radius: 8px; margin: .8em 0; }
.article-content table { border-collapse: collapse; width: 100%; margin: .8em 0; }
.article-content th, .article-content td { border: 1px solid var(--surface-600); padding: .5em .8em; text-align: left; }
.article-content th { background: var(--surface-700); font-weight: 600; }
.mode-btn { transition: all .2s; }
.mode-btn.active { color: #0054e6; background: rgba(0,84,230,.08); border-color: rgba(0,84,230,.3); }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link active" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-3xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<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>
URL Article Parser
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Парсер <span class="text-accent">статей</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">извлечение контента из любой веб-страницы</p>
</div>
<!-- Input Card -->
<div id="inputCard" 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="flex gap-2">
<input type="url" id="urlInput" placeholder="https://example.com/article"
class="flex-1 px-4 py-3 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-xl text-sm font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800"
>
<button id="parseBtn"
class="px-6 py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20
active:scale-[0.98] whitespace-nowrap">
Парсить
</button>
</div>
<!-- Mode buttons -->
<div class="flex gap-2 mt-4">
<button class="mode-btn active flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors" data-mode="full">
Полный
</button>
<button class="mode-btn flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors" data-mode="metadata">
Метаданные
</button>
<button class="mode-btn flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors" data-mode="text">
Текст
</button>
<button class="mode-btn flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors" data-mode="preview">
Превью
</button>
</div>
</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>
<!-- Loading -->
<div id="loadingBlock" class="hidden mt-5">
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-8 backdrop-blur-sm shadow-sm dark:shadow-none text-center processing">
<div class="inline-flex items-center gap-3">
<svg class="animate-spin w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>
<span class="text-sm dark:text-gray-400 text-gray-500 font-mono">Загружаю и парсю страницу...</span>
</div>
</div>
</div>
<!-- Result: Full article -->
<div id="resultFull" class="hidden mt-5 fade-up space-y-4">
<!-- Preview card -->
<div id="previewCard" class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl overflow-hidden backdrop-blur-sm shadow-sm dark:shadow-none">
<img id="leadImage" class="w-full h-48 object-cover hidden" alt="">
<div class="p-5 sm:p-7">
<div class="flex items-center gap-2 mb-2">
<span id="siteName" class="text-xs font-mono dark:text-gray-500 text-gray-400"></span>
<span id="articleDate" class="text-xs font-mono dark:text-gray-500 text-gray-400"></span>
</div>
<h2 id="articleTitle" class="text-xl font-bold dark:text-white text-gray-900 mb-2"></h2>
<p id="articleAuthor" class="text-sm dark:text-gray-500 text-gray-400 hidden"></p>
<p id="articleExcerpt" class="text-sm dark:text-gray-400 text-gray-500 mt-2 leading-relaxed"></p>
<div class="flex items-center gap-3 mt-3">
<span id="wordCount" class="text-xs font-mono dark:text-gray-600 text-gray-400"></span>
<span id="articleLang" class="text-xs font-mono dark:text-gray-600 text-gray-400"></span>
<span id="imageCount" class="text-xs font-mono dark:text-gray-600 text-gray-400"></span>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-2 flex-wrap">
<button id="copyJsonBtn" class="px-4 py-2 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Скопировать JSON
</button>
<button id="copyTextBtn" class="px-4 py-2 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200">
Скопировать текст
</button>
<button id="copyHtmlBtn" class="px-4 py-2 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200">
Скопировать HTML
</button>
<button id="clearBtn" class="ml-auto px-4 py-2 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">
Очистить
</button>
</div>
<!-- Article content -->
<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">
<div class="flex items-center justify-between mb-4 pb-3 border-b dark:border-surface-600 border-gray-200">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Контент</span>
<div class="flex gap-2">
<button id="viewHtmlBtn" class="mode-btn active px-3 py-1 rounded text-xs font-medium dark:text-gray-400 text-gray-500 border dark:border-surface-600 border-gray-200" data-view="html">HTML</button>
<button id="viewTextBtn" class="mode-btn px-3 py-1 rounded text-xs font-medium dark:text-gray-400 text-gray-500 border dark:border-surface-600 border-gray-200" data-view="text">Text</button>
<button id="viewJsonBtn" class="mode-btn px-3 py-1 rounded text-xs font-medium dark:text-gray-400 text-gray-500 border dark:border-surface-600 border-gray-200" data-view="json">JSON</button>
</div>
</div>
<div id="contentHtml" class="article-content"></div>
<pre id="contentText" class="hidden whitespace-pre-wrap text-sm font-mono dark:text-gray-300 text-gray-600 leading-relaxed max-h-[600px] overflow-y-auto"></pre>
<pre id="contentJson" class="hidden whitespace-pre-wrap text-xs font-mono dark:text-gray-300 text-gray-600 leading-relaxed max-h-[600px] overflow-y-auto dark:bg-surface-700 bg-gray-50 p-4 rounded-xl"></pre>
</div>
</div>
<!-- Result: Metadata only -->
<div id="resultMetadata" class="hidden mt-5 fade-up">
<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">
<div class="flex items-center justify-between mb-4 pb-3 border-b dark:border-surface-600 border-gray-200">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Метаданные</span>
<button id="copyMetaJsonBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Скопировать
</button>
</div>
<pre id="metadataJson" class="whitespace-pre-wrap text-xs font-mono dark:text-gray-300 text-gray-600 leading-relaxed dark:bg-surface-700 bg-gray-50 p-4 rounded-xl"></pre>
</div>
</div>
<!-- Result: Text only -->
<div id="resultText" class="hidden mt-5 fade-up">
<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">
<div class="flex items-center justify-between mb-4 pb-3 border-b dark:border-surface-600 border-gray-200">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Текст</span>
<button id="copyPlainTextBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Скопировать
</button>
</div>
<pre id="plainText" class="whitespace-pre-wrap text-sm dark:text-gray-300 text-gray-600 leading-relaxed max-h-[600px] overflow-y-auto"></pre>
</div>
</div>
<!-- Result: Preview -->
<div id="resultPreview" class="hidden mt-5 fade-up">
<div id="previewLinkCard" class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl overflow-hidden backdrop-blur-sm shadow-sm dark:shadow-none">
<img id="previewImg" class="w-full h-52 object-cover hidden" alt="">
<div class="p-5 sm:p-7">
<p id="previewSite" class="text-xs font-mono dark:text-gray-500 text-gray-400 mb-1"></p>
<h3 id="previewTitle" class="text-lg font-bold dark:text-white text-gray-900 mb-2"></h3>
<p id="previewDesc" class="text-sm dark:text-gray-400 text-gray-500 leading-relaxed"></p>
</div>
</div>
<button id="copyPreviewJsonBtn" class="mt-3 px-4 py-2 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Скопировать JSON
</button>
</div>
<!-- API Reference -->
<div class="mt-8 fade-up">
<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">
<h3 class="text-sm font-semibold dark:text-white text-gray-800 mb-3">API</h3>
<div class="space-y-2 text-xs font-mono dark:text-gray-400 text-gray-500 leading-relaxed">
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/parse?url=URL</code> — полный парсинг (title, content, images, links)</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/metadata?url=URL</code> — только метаданные</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/text?url=URL</code> — только plain text</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/preview?url=URL</code> — превью-карточка (title + image + description)</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/health</code> — проверка доступности</p>
</div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
// State
let currentMode = 'full';
let lastResult = null;
let lastMetadata = null;
let lastText = null;
let lastPreview = null;
const $ = id => document.getElementById(id);
// Mode buttons
document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn[data-mode]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.dataset.mode;
});
});
// View toggle (HTML/Text/JSON)
document.querySelectorAll('.mode-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn[data-view]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
$('contentHtml').classList.toggle('hidden', view !== 'html');
$('contentText').classList.toggle('hidden', view !== 'text');
$('contentJson').classList.toggle('hidden', view !== 'json');
});
});
// Parse
$('parseBtn').addEventListener('click', doParse);
$('urlInput').addEventListener('keydown', e => { if (e.key === 'Enter') doParse(); });
async function doParse() {
const url = $('urlInput').value.trim();
if (!url) return;
hideAll();
$('loadingBlock').classList.remove('hidden');
try {
if (currentMode === 'full') {
const res = await fetch('/parse?url=' + encodeURIComponent(url));
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
lastResult = data;
showFullResult(data);
} else if (currentMode === 'metadata') {
const res = await fetch('/metadata?url=' + encodeURIComponent(url));
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
lastMetadata = data;
showMetadata(data);
} else if (currentMode === 'text') {
const res = await fetch('/text?url=' + encodeURIComponent(url));
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка');
}
const text = await res.text();
lastText = text;
showText(text);
} else if (currentMode === 'preview') {
const res = await fetch('/preview?url=' + encodeURIComponent(url));
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
lastPreview = data;
showPreview(data);
}
} catch (err) {
$('errorBlock').textContent = err.message;
$('errorBlock').classList.remove('hidden');
} finally {
$('loadingBlock').classList.add('hidden');
}
}
function hideAll() {
$('errorBlock').classList.add('hidden');
$('resultFull').classList.add('hidden');
$('resultMetadata').classList.add('hidden');
$('resultText').classList.add('hidden');
$('resultPreview').classList.add('hidden');
}
function showFullResult(data) {
// Lead image
if (data.lead_image) {
$('leadImage').src = data.lead_image;
$('leadImage').classList.remove('hidden');
} else {
$('leadImage').classList.add('hidden');
}
$('articleTitle').textContent = data.title || 'Без заголовка';
$('siteName').textContent = data.site || '';
$('articleDate').textContent = data.date_published ? new Date(data.date_published).toLocaleDateString('ru') : '';
if (data.author) {
$('articleAuthor').textContent = data.author;
$('articleAuthor').classList.remove('hidden');
} else {
$('articleAuthor').classList.add('hidden');
}
$('articleExcerpt').textContent = data.excerpt || '';
$('wordCount').textContent = data.word_count ? data.word_count + ' слов' : '';
$('articleLang').textContent = data.lang ? 'lang: ' + data.lang : '';
$('imageCount').textContent = data.images ? data.images.length + ' изобр.' : '';
// Content views
$('contentHtml').innerHTML = data.content_html || '<p class="dark:text-gray-500 text-gray-400">Контент не извлечён</p>';
$('contentText').textContent = data.content_text || '';
$('contentJson').textContent = JSON.stringify(data, null, 2);
// Reset view to HTML
document.querySelectorAll('.mode-btn[data-view]').forEach(b => b.classList.remove('active'));
$('viewHtmlBtn').classList.add('active');
$('contentHtml').classList.remove('hidden');
$('contentText').classList.add('hidden');
$('contentJson').classList.add('hidden');
$('resultFull').classList.remove('hidden');
}
function showMetadata(data) {
$('metadataJson').textContent = JSON.stringify(data, null, 2);
$('resultMetadata').classList.remove('hidden');
}
function showText(text) {
$('plainText').textContent = text;
$('resultText').classList.remove('hidden');
}
function showPreview(data) {
if (data.image) {
$('previewImg').src = data.image;
$('previewImg').classList.remove('hidden');
} else {
$('previewImg').classList.add('hidden');
}
$('previewSite').textContent = data.site || '';
$('previewTitle').textContent = data.title || '';
$('previewDesc').textContent = data.description || '';
$('resultPreview').classList.remove('hidden');
}
// Copy helpers
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
btn.classList.add('text-accent');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('text-accent'); }, 1500);
});
}
$('copyJsonBtn').addEventListener('click', () => {
if (lastResult) copyToClipboard(JSON.stringify(lastResult, null, 2), $('copyJsonBtn'));
});
$('copyTextBtn').addEventListener('click', () => {
if (lastResult) copyToClipboard(lastResult.content_text || '', $('copyTextBtn'));
});
$('copyHtmlBtn').addEventListener('click', () => {
if (lastResult) copyToClipboard(lastResult.content_html || '', $('copyHtmlBtn'));
});
$('copyMetaJsonBtn').addEventListener('click', () => {
if (lastMetadata) copyToClipboard(JSON.stringify(lastMetadata, null, 2), $('copyMetaJsonBtn'));
});
$('copyPlainTextBtn').addEventListener('click', () => {
if (lastText) copyToClipboard(lastText, $('copyPlainTextBtn'));
});
$('copyPreviewJsonBtn').addEventListener('click', () => {
if (lastPreview) copyToClipboard(JSON.stringify(lastPreview, null, 2), $('copyPreviewJsonBtn'));
});
// Clear
$('clearBtn').addEventListener('click', () => {
hideAll();
lastResult = null;
lastMetadata = null;
lastText = null;
lastPreview = null;
$('urlInput').value = '';
$('urlInput').focus();
});
// Auto-focus
$('urlInput').focus();
</script>
</body>
</html>

482
public/password.html Normal file
View File

@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Password Generator — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
--select-bg: #f0f0f3;
--select-color: #1a1a2e;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
--select-bg: #091020;
--select-color: #e0e0e0;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
select, select option { background: var(--select-bg); color: var(--select-color); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
/* Toggle switch */
.toggle { position: relative; width: 40px; height: 22px; border-radius: 11px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
.toggle.off { background: var(--surface-600); }
.toggle.on { background: #0054e6; }
.toggle-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
.toggle.on .toggle-knob { left: 20px; }
/* Strength bar */
.strength-bar { height: 6px; border-radius: 3px; background: var(--surface-700); overflow: hidden; }
.strength-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; }
/* Password display */
.pw-display {
font-family: 'JetBrains Mono', monospace;
word-break: break-all;
line-height: 1.6;
}
/* Range slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--surface-700);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #0054e6;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0,84,230,0.3);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #0054e6;
cursor: pointer;
border: none;
box-shadow: 0 1px 4px rgba(0,84,230,0.3);
}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link active" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-2xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<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>
Crypto &middot; Client-side
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Генератор <span class="text-accent">паролей</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">криптографически стойкая генерация</p>
</div>
<!-- Settings 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">
<!-- Length -->
<div class="mb-5">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-2 font-mono uppercase tracking-wider">Длина</label>
<div class="flex items-center gap-4">
<input type="range" id="lengthRange" min="4" max="128" value="16" class="flex-1">
<input type="number" id="lengthInput" min="4" max="128" value="16"
class="w-20 px-3 py-2 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm font-mono text-center focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</div>
</div>
<!-- Toggles -->
<div class="space-y-3 mb-5">
<div class="flex items-center justify-between">
<span class="text-sm dark:text-gray-300 text-gray-600">Верхний регистр <span class="font-mono text-xs dark:text-gray-500 text-gray-400">(A-Z)</span></span>
<div class="toggle on" data-opt="upper"><div class="toggle-knob"></div></div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm dark:text-gray-300 text-gray-600">Нижний регистр <span class="font-mono text-xs dark:text-gray-500 text-gray-400">(a-z)</span></span>
<div class="toggle on" data-opt="lower"><div class="toggle-knob"></div></div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm dark:text-gray-300 text-gray-600">Цифры <span class="font-mono text-xs dark:text-gray-500 text-gray-400">(0-9)</span></span>
<div class="toggle on" data-opt="digits"><div class="toggle-knob"></div></div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm dark:text-gray-300 text-gray-600">Символы <span class="font-mono text-xs dark:text-gray-500 text-gray-400">(!@#$%^&*...)</span></span>
<div class="toggle off" data-opt="symbols"><div class="toggle-knob"></div></div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm dark:text-gray-300 text-gray-600">Исключить похожие <span class="font-mono text-xs dark:text-gray-500 text-gray-400">(l, 1, I, O, 0, o)</span></span>
<div class="toggle off" data-opt="exclude"><div class="toggle-knob"></div></div>
</div>
</div>
<!-- Count -->
<div class="mb-5">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Количество</label>
<select id="countSelect" 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">
<option value="1">1 пароль</option>
<option value="5">5 паролей</option>
<option value="10">10 паролей</option>
<option value="25">25 паролей</option>
</select>
</div>
<!-- Generate button -->
<button id="generateBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20
active:scale-[0.98]">
Сгенерировать
</button>
</div>
<!-- Result -->
<div id="resultCard" class="mt-5 fade-up">
<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">
<!-- Strength indicator -->
<div id="strengthBlock" class="mb-5 hidden">
<div class="flex items-center justify-between mb-2">
<span id="strengthLabel" class="text-xs font-medium font-mono uppercase tracking-wider"></span>
<span id="entropyLabel" class="text-xs font-mono dark:text-gray-500 text-gray-400"></span>
</div>
<div class="strength-bar">
<div id="strengthFill" class="strength-bar-fill" style="width:0%"></div>
</div>
</div>
<!-- Single password display -->
<div id="singleResult" class="hidden">
<div class="flex items-start gap-3">
<div id="passwordDisplay" class="pw-display flex-1 text-lg sm:text-xl dark:text-white text-gray-900 dark:bg-surface-700/50 bg-gray-50 rounded-xl px-4 py-3 border dark:border-surface-600 border-gray-200 select-all"></div>
<button id="copyBtn" class="px-4 py-3 rounded-xl text-sm font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200 whitespace-nowrap flex-shrink-0">
Копировать
</button>
</div>
</div>
<!-- Multi password display -->
<div id="multiResult" class="hidden space-y-2">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Пароли</span>
<button id="copyAllBtn" class="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">
Копировать все
</button>
</div>
<div id="passwordList"></div>
</div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
// State
const opts = { upper: true, lower: true, digits: true, symbols: false, exclude: false };
const CHARSETS = {
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
lower: 'abcdefghijklmnopqrstuvwxyz',
digits: '0123456789',
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?/~`',
};
const SIMILAR = 'l1IO0o';
// Toggle handlers
document.querySelectorAll('.toggle[data-opt]').forEach(el => {
el.addEventListener('click', () => {
const opt = el.dataset.opt;
opts[opt] = !opts[opt];
el.classList.toggle('on', opts[opt]);
el.classList.toggle('off', !opts[opt]);
});
});
// Length sync
const lengthRange = document.getElementById('lengthRange');
const lengthInput = document.getElementById('lengthInput');
lengthRange.addEventListener('input', () => { lengthInput.value = lengthRange.value; });
lengthInput.addEventListener('input', () => {
let v = parseInt(lengthInput.value, 10);
if (v < 4) v = 4;
if (v > 128) v = 128;
lengthRange.value = v;
});
function buildCharset() {
let cs = '';
if (opts.upper) cs += CHARSETS.upper;
if (opts.lower) cs += CHARSETS.lower;
if (opts.digits) cs += CHARSETS.digits;
if (opts.symbols) cs += CHARSETS.symbols;
if (opts.exclude) {
cs = cs.split('').filter(c => !SIMILAR.includes(c)).join('');
}
return cs;
}
function generatePassword(length, charset) {
const arr = new Uint32Array(length);
crypto.getRandomValues(arr);
let pw = '';
for (let i = 0; i < length; i++) {
pw += charset[arr[i] % charset.length];
}
return pw;
}
function calcEntropy(length, charsetSize) {
if (charsetSize <= 1) return 0;
return Math.floor(length * Math.log2(charsetSize));
}
function getStrength(entropy) {
if (entropy < 28) return { label: 'Слабый', color: 'text-danger', bar: 'bg-danger', pct: 15 };
if (entropy < 36) return { label: 'Средний', color: 'text-warn', bar: 'bg-warn', pct: 35 };
if (entropy < 60) return { label: 'Сильный', color: 'text-accent/70', bar: 'bg-accent/60', pct: 65 };
return { label: 'Очень сильный', color: 'text-accent', bar: 'bg-accent', pct: 100 };
}
function showStrength(length, charsetSize) {
const block = document.getElementById('strengthBlock');
const entropy = calcEntropy(length, charsetSize);
const s = getStrength(entropy);
document.getElementById('strengthLabel').textContent = s.label;
document.getElementById('strengthLabel').className = 'text-xs font-medium font-mono uppercase tracking-wider ' + s.color;
document.getElementById('entropyLabel').textContent = entropy + ' бит энтропии';
const fill = document.getElementById('strengthFill');
fill.className = 'strength-bar-fill ' + s.bar;
fill.style.width = s.pct + '%';
block.classList.remove('hidden');
}
function copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
btn.classList.add('text-accent');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('text-accent'); }, 1500);
});
}
document.getElementById('generateBtn').addEventListener('click', generate);
function generate() {
const charset = buildCharset();
if (!charset) {
alert('Выберите хотя бы один набор символов');
return;
}
const length = parseInt(lengthInput.value, 10) || 16;
const count = parseInt(document.getElementById('countSelect').value, 10);
showStrength(length, charset.length);
const singleResult = document.getElementById('singleResult');
const multiResult = document.getElementById('multiResult');
if (count === 1) {
const pw = generatePassword(length, charset);
document.getElementById('passwordDisplay').textContent = pw;
singleResult.classList.remove('hidden');
multiResult.classList.add('hidden');
document.getElementById('copyBtn').onclick = () => copyText(pw, document.getElementById('copyBtn'));
} else {
const passwords = [];
for (let i = 0; i < count; i++) {
passwords.push(generatePassword(length, charset));
}
const list = document.getElementById('passwordList');
list.innerHTML = '';
passwords.forEach((pw, i) => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 py-2' + (i > 0 ? ' border-t dark:border-surface-600/50 border-gray-100' : '');
row.innerHTML = `
<span class="pw-display flex-1 text-sm dark:text-gray-200 text-gray-700 select-all break-all">${escapeHtml(pw)}</span>
<button class="copy-row-btn px-3 py-1.5 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-400 text-gray-500 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200 whitespace-nowrap flex-shrink-0">Копировать</button>
`;
row.querySelector('.copy-row-btn').addEventListener('click', function() { copyText(pw, this); });
list.appendChild(row);
});
singleResult.classList.add('hidden');
multiResult.classList.remove('hidden');
document.getElementById('copyAllBtn').onclick = () => copyText(passwords.join('\n'), document.getElementById('copyAllBtn'));
}
document.getElementById('resultCard').classList.remove('hidden');
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Generate on load
generate();
</script>
</body>
</html>

412
public/placeholder.html Normal file
View File

@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Placeholder — генератор заглушек</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root {
--bg: #f5f5f7;
--bg-grad1: rgba(0, 67, 184, 0.04);
--bg-grad2: rgba(0, 50, 140, 0.03);
--surface-800: rgba(255,255,255,0.85);
--surface-700: #f0f0f3;
--surface-600: #dde0e6;
--text-primary: #1a1a2e;
--text-secondary: #555;
--text-muted: #888;
--select-bg: #f0f0f3;
--select-color: #1a1a2e;
}
.dark {
--bg: #060c18;
--bg-grad1: rgba(0, 84, 230, 0.03);
--bg-grad2: rgba(0, 67, 184, 0.02);
--surface-800: rgba(9,16,32,0.8);
--surface-700: #0d1828;
--surface-600: #162040;
--text-primary: #fff;
--text-secondary: #c0c0c0;
--text-muted: #666;
--select-bg: #091020;
--select-color: #e0e0e0;
}
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 50%, var(--bg-grad1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, var(--bg-grad2) 0%, transparent 50%);
min-height: 100vh;
transition: background 0.3s;
}
.dark .noise::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.theme-toggle { position: relative; width: 44px; height: 24px; border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.dark .theme-toggle { background: #162040; }
.theme-toggle { background: #dde0e6; }
.theme-toggle-knob { position: absolute; top: 2px; width: 20px; height: 20px; border-radius: 50%; transition: left 0.3s, background 0.3s; display: flex; align-items: center; justify-content: center; }
.dark .theme-toggle-knob { left: 22px; background: #0d1828; }
.theme-toggle-knob { left: 2px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
select, select option { background: var(--select-bg); color: var(--select-color); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.4s ease-out forwards; }
.fade-up-delay { animation: fadeUp 0.4s ease-out 0.1s forwards; opacity: 0; }
.preview-frame {
background: repeating-conic-gradient(rgba(128,128,128,.08) 0% 25%, transparent 0% 50%) 50%/16px 16px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
overflow: hidden;
}
.preview-frame img { max-width: 100%; max-height: 400px; display: block; border-radius: 8px; }
.color-swatch {
width: 36px; height: 36px; border-radius: 8px; border: 2px solid var(--surface-600);
cursor: pointer; transition: transform .15s;
}
.color-swatch:hover { transform: scale(1.1); }
input[type="color"] { position: absolute; opacity: 0; width: 0; height: 0; }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link active" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-2xl mx-auto px-4 py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<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>
PNG &middot; JPG &middot; WebP
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
<span class="text-accent">Placeholder</span> генератор
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">изображения-заглушки любого размера</p>
</div>
<!-- Controls 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">
<!-- Size -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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="number" id="width" value="600" min="1" max="4000" 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 font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</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="number" id="height" value="400" min="1" max="4000" 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 font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</div>
</div>
<!-- Colors -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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="flex items-center gap-2">
<div class="relative">
<div id="bgSwatch" class="color-swatch" style="background:#3B82F6"></div>
<input type="color" id="bgPicker" value="#3B82F6">
</div>
<input type="text" id="bgColor" value="3B82F6" maxlength="20" class="flex-1 px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</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="flex items-center gap-2">
<div class="relative">
<div id="fgSwatch" class="color-swatch" style="background:#FFFFFF"></div>
<input type="color" id="fgPicker" value="#FFFFFF">
</div>
<input type="text" id="fgColor" value="FFFFFF" maxlength="20" class="flex-1 px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</div>
</div>
</div>
<!-- Text & Font size -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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="text" id="text" placeholder="600x400" 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 font-mono focus:outline-none focus:border-accent/50 transition-colors dark:text-white text-gray-800">
</div>
<div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label>
<select id="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">
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="webp">WebP</option>
</select>
</div>
</div>
<!-- Generate URL -->
<div class="mb-4">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">URL</label>
<div class="flex gap-2">
<input type="text" id="urlField" readonly class="flex-1 px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-xs font-mono focus:outline-none dark:text-gray-300 text-gray-600 truncate">
<button id="copyUrlBtn" class="px-4 py-2.5 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200 whitespace-nowrap">
Копировать
</button>
</div>
</div>
<!-- Generate button -->
<button id="generateBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20
active:scale-[0.98]">
Сгенерировать
</button>
</div>
<!-- Preview -->
<div id="previewCard" class="mt-5 fade-up">
<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">
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-mono dark:text-gray-500 text-gray-400 uppercase tracking-wider">Превью</span>
<a id="downloadLink" href="#" download class="px-3 py-1.5 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-300 text-gray-600 hover:text-accent transition-colors border dark:border-surface-600 border-gray-200 no-underline">
Скачать
</a>
</div>
<div class="preview-frame p-3">
<img id="previewImg" src="/placeholder-img/600x400/3B82F6/FFFFFF" alt="placeholder">
</div>
</div>
</div>
<!-- API Reference -->
<div class="mt-5 fade-up">
<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">
<h3 class="text-sm font-semibold dark:text-white text-gray-800 mb-3">API</h3>
<div class="space-y-2 text-xs font-mono dark:text-gray-400 text-gray-500 leading-relaxed">
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/placeholder-img/:WxH/:bg/:fg</code> — картинка без текста</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">/placeholder-img/:WxH/:bg/:fg?text=Hello</code>с текстом</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">?format=png|jpg|webp</code> — формат выходного файла</p>
<p><code class="dark:bg-surface-700 bg-gray-100 px-2 py-0.5 rounded">?fontsize=30</code> — размер шрифта (авто по умолчанию)</p>
<p class="dark:text-gray-500 text-gray-400 pt-1">Цвета: HEX (<code class="dark:bg-surface-700 bg-gray-100 px-1.5 py-0.5 rounded">3B82F6</code>) или имя (<code class="dark:bg-surface-700 bg-gray-100 px-1.5 py-0.5 rounded">red</code>, <code class="dark:bg-surface-700 bg-gray-100 px-1.5 py-0.5 rounded">yellow</code>)</p>
</div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
// Elements
const widthEl = document.getElementById('width');
const heightEl = document.getElementById('height');
const bgColorEl = document.getElementById('bgColor');
const fgColorEl = document.getElementById('fgColor');
const bgPickerEl = document.getElementById('bgPicker');
const fgPickerEl = document.getElementById('fgPicker');
const bgSwatchEl = document.getElementById('bgSwatch');
const fgSwatchEl = document.getElementById('fgSwatch');
const textEl = document.getElementById('text');
const formatEl = document.getElementById('format');
const urlField = document.getElementById('urlField');
const previewImg = document.getElementById('previewImg');
const downloadLink = document.getElementById('downloadLink');
// Color picker sync
bgSwatchEl.addEventListener('click', () => bgPickerEl.click());
fgSwatchEl.addEventListener('click', () => fgPickerEl.click());
bgPickerEl.addEventListener('input', e => {
const hex = e.target.value.replace('#', '').toUpperCase();
bgColorEl.value = hex;
bgSwatchEl.style.background = e.target.value;
updateUrl();
});
fgPickerEl.addEventListener('input', e => {
const hex = e.target.value.replace('#', '').toUpperCase();
fgColorEl.value = hex;
fgSwatchEl.style.background = e.target.value;
updateUrl();
});
bgColorEl.addEventListener('input', () => {
const v = bgColorEl.value.trim();
if (/^[0-9A-Fa-f]{6}$/.test(v)) {
bgPickerEl.value = '#' + v;
bgSwatchEl.style.background = '#' + v;
} else {
bgSwatchEl.style.background = v;
}
updateUrl();
});
fgColorEl.addEventListener('input', () => {
const v = fgColorEl.value.trim();
if (/^[0-9A-Fa-f]{6}$/.test(v)) {
fgPickerEl.value = '#' + v;
fgSwatchEl.style.background = '#' + v;
} else {
fgSwatchEl.style.background = v;
}
updateUrl();
});
[widthEl, heightEl, textEl, formatEl].forEach(el => el.addEventListener('input', updateUrl));
function buildPath() {
const w = widthEl.value || 600;
const h = heightEl.value || 400;
const bg = bgColorEl.value.trim() || '3B82F6';
const fg = fgColorEl.value.trim() || 'FFFFFF';
let url = `/placeholder-img/${w}x${h}/${bg}/${fg}`;
const params = [];
const txt = textEl.value.trim();
if (txt) params.push('text=' + encodeURIComponent(txt));
const fmt = formatEl.value;
if (fmt !== 'png') params.push('format=' + fmt);
if (params.length) url += '?' + params.join('&');
return url;
}
function updateUrl() {
const p = buildPath();
urlField.value = location.origin + p;
}
function generate() {
const p = buildPath();
previewImg.src = p;
downloadLink.href = p;
updateUrl();
}
document.getElementById('generateBtn').addEventListener('click', generate);
// Copy URL
document.getElementById('copyUrlBtn').addEventListener('click', () => {
navigator.clipboard.writeText(urlField.value).then(() => {
const btn = document.getElementById('copyUrlBtn');
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
btn.classList.add('text-accent');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('text-accent'); }, 1500);
});
});
// Init
updateUrl();
</script>
</body>
</html>

712
public/redirects.html Normal file
View File

@ -0,0 +1,712 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirect Analyzer — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root{--bg:#f5f5f7;--bg-grad1:rgba(0,67,184,.04);--bg-grad2:rgba(0,50,140,.03);--surface-800:rgba(255,255,255,0.85);--surface-700:#f0f0f3;--surface-600:#dde0e6;--text-primary:#1a1a2e;--text-secondary:#555;--text-muted:#888;--select-bg:#fff;--select-color:#1a1a2e}
.dark{--bg:#060c18;--bg-grad1:rgba(0,84,230,.03);--bg-grad2:rgba(0,67,184,.02);--surface-800:rgba(9,16,32,0.8);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--select-bg:#091020;--select-color:#e0e0e0}
body{background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,var(--bg-grad1) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,var(--bg-grad2) 0%,transparent 50%);min-height:100vh;transition:background .3s}
.dark .noise::before{content:'';position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");pointer-events:none;z-index:0}
.theme-toggle{position:relative;width:44px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s}
.dark .theme-toggle{background:#162040}
.theme-toggle{background:#dde0e6}
.theme-toggle-knob{position:absolute;top:2px;width:20px;height:20px;border-radius:50%;transition:left .3s,background .3s;display:flex;align-items:center;justify-content:center}
.dark .theme-toggle-knob{left:22px;background:#0d1828}
.theme-toggle-knob{left:2px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
/* Redirect Analyzer UI */
.panel {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
overflow: hidden;
}
.url-input {
flex: 1;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 0 12px;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
height: 40px;
min-width: 0;
}
.url-input:focus { border-color: rgba(0,84,230,.5); }
.url-input::placeholder { color: var(--text-muted); }
select.ctrl-select {
background: var(--select-bg);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 0 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--select-color);
outline: none;
cursor: pointer;
height: 40px;
white-space: nowrap;
}
select.ctrl-select:focus { border-color: rgba(0,84,230,.5); }
.analyze-btn {
background: #0054e6;
color: #fff;
border: none;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex; align-items: center; gap: 6px;
transition: background .2s;
white-space: nowrap;
height: 40px;
font-family: 'Manrope', sans-serif;
}
.analyze-btn:hover { background: #0043b8; }
.analyze-btn:disabled { background: #0054e680; cursor: not-allowed; }
/* Chain cards */
.chain-step {
position: relative;
}
.step-card {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 14px 16px;
transition: border-color .2s;
}
.step-card:hover { border-color: rgba(0,84,230,.3); }
.step-card.final { border-color: rgba(0,168,107,.3); background: rgba(0,168,107,.04); }
.step-num {
width: 24px; height: 24px;
border-radius: 50%;
background: rgba(0,84,230,.15);
color: #6b9fff;
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.step-num.final { background: rgba(0,168,107,.15); color: #00a86b; }
.status-badge {
display: inline-flex; align-items: center;
padding: 2px 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.s-2xx { background: rgba(0,168,107,.15); color: #00a86b; }
.s-3xx { background: rgba(0,84,230,.15); color: #6b9fff; }
.s-4xx { background: rgba(255,165,0,.15); color: #ffa500; }
.s-5xx { background: rgba(255,82,82,.15); color: #ff5252; }
.arrow-connector {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
color: var(--text-muted);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
.arrow-line {
flex: 1;
height: 1px;
background: var(--surface-600);
}
.final-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
background: rgba(0,168,107,.15);
color: #00a86b;
text-transform: uppercase;
letter-spacing: .05em;
}
/* Summary */
.stat-card {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 14px 18px;
display: flex; flex-direction: column; gap: 4px;
}
.stat-val {
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: var(--text-primary);
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* Issues */
.issue-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.issue-error { background: rgba(255,82,82,.07); color: #ff5252; border: 1px solid rgba(255,82,82,.2); }
.issue-warn { background: rgba(255,214,0,.07); color: #ffd600; border: 1px solid rgba(255,214,0,.15); }
.issue-info { background: rgba(0,84,230,.07); color: #6b9fff; border: 1px solid rgba(0,84,230,.15); }
/* Headers table */
.headers-table { width: 100%; border-collapse: collapse; font-size: 11px; font-family: 'JetBrains Mono', monospace; }
.headers-table th { text-align: left; padding: 5px 10px; color: var(--text-muted); border-bottom: 1px solid var(--surface-600); font-weight: 600; }
.headers-table td { padding: 5px 10px; color: var(--text-secondary); border-bottom: 1px solid rgba(255,255,255,.03); vertical-align: top; word-break: break-all; }
.headers-table td:first-child { color: #6b9fff; white-space: nowrap; min-width: 160px; }
/* Details accordion */
.details-accordion summary {
cursor: pointer;
list-style: none;
display: flex; align-items: center; gap: 6px;
padding: 10px 14px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
border-radius: 8px;
background: var(--surface-700);
border: 1px solid var(--surface-600);
user-select: none;
transition: color .15s;
}
.details-accordion summary:hover { color: var(--text-secondary); }
.details-accordion[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom-color: transparent; }
.details-accordion .details-body {
border: 1px solid var(--surface-600);
border-top: none;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
overflow: hidden;
}
/* Export btns */
.export-btn {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 7px;
color: var(--text-muted);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
padding: 6px 14px;
cursor: pointer;
transition: all .15s;
display: flex; align-items: center; gap: 5px;
}
.export-btn:hover { color: #0054e6; border-color: rgba(0,84,230,.4); background: rgba(0,84,230,.06); }
/* Spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 0.8s linear infinite; }
/* Copy button */
.copy-btn {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 6px;
color: var(--text-muted);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
padding: 4px 10px;
cursor: pointer;
transition: all .15s;
}
.copy-btn:hover { color: #0054e6; border-color: rgba(0,84,230,.4); }
/* URL truncate */
.url-text {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
flex: 1;
}
.url-text a { color: #6b9fff; text-decoration: none; }
.url-text a:hover { text-decoration: underline; }
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">IW</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
</a>
<a href="/compress" class="sidebar-link" title="Картинки">
<svg width="22" height="22" 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>
</a>
<a href="/md" class="sidebar-link" title="Markdown">
<svg width="22" height="22" 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>
</a>
<a href="/placeholder" class="sidebar-link" title="Placeholder">
<svg width="22" height="22" 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>
</a>
<a href="/parser" class="sidebar-link" title="Parser">
<svg width="22" height="22" 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>
</a>
<a href="/password" class="sidebar-link" title="Password">
<svg width="22" height="22" 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>
</a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer">
<svg width="22" height="22" 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>
</a>
<a href="/converter" class="sidebar-link" title="Converter">
<svg width="22" height="22" 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>
</a>
<a href="/formatter" class="sidebar-link" title="Formatter">
<svg width="22" height="22" 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>
</a>
<a href="/editor" class="sidebar-link" title="Editor">
<svg width="22" height="22" 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>
</a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link active" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<!-- Main -->
<div class="main-content">
<div class="relative z-10 p-6 max-w-5xl mx-auto">
<!-- Header -->
<div class="mb-8 text-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>
HTTP · HTTPS · 301 · 302
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Redirect <span class="text-accent">Анализатор</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">анализ цепочки HTTP-редиректов</p>
</div>
<!-- URL Bar -->
<div class="panel mb-6">
<div class="p-4 flex flex-wrap gap-3 items-center">
<input id="urlInput" type="text" class="url-input" placeholder="https://example.com или example.com" />
<select id="uaSelect" class="ctrl-select">
<option value="desktop">Десктоп</option>
<option value="mobile">Мобильный</option>
<option value="googlebot">Googlebot</option>
</select>
<select id="methodSelect" class="ctrl-select">
<option value="GET">GET</option>
<option value="HEAD">HEAD</option>
</select>
<button id="analyzeBtn" class="analyze-btn">
<svg id="btnIcon" width="15" height="15" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 15.803a7.5 7.5 0 0010.607 0z"/></svg>
Анализировать
</button>
</div>
</div>
<!-- States -->
<div id="stateEmpty">
<div class="panel p-10 text-center">
<svg class="mx-auto mb-4 opacity-20" width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1"><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>
<p class="text-sm font-mono" style="color:var(--text-muted)">Введите URL для анализа цепочки редиректов</p>
<p class="text-xs font-mono mt-2" style="color:var(--text-muted)">Пример: <span style="color:#6b9fff">http://google.com</span></p>
</div>
</div>
<div id="stateLoading" class="hidden">
<div class="panel p-10 text-center">
<svg class="spinner mx-auto mb-4" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#0054e6" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
<p class="text-sm font-mono" style="color:var(--text-muted)">Анализируем...</p>
</div>
</div>
<div id="stateError" class="hidden">
<div class="panel p-6">
<div class="flex items-start gap-3">
<svg width="18" height="18" fill="none" stroke="#ff5252" viewBox="0 0 24 24" stroke-width="2" class="flex-shrink-0 mt-0.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
<div>
<p class="text-sm font-semibold" style="color:#ff5252">Ошибка</p>
<p id="errorMsg" class="text-sm font-mono mt-1" style="color:var(--text-muted)"></p>
</div>
</div>
</div>
</div>
<div id="stateResult" class="hidden space-y-6">
<!-- Summary stats -->
<div id="summaryBlock" class="grid grid-cols-2 sm:grid-cols-4 gap-3"></div>
<!-- Final URL -->
<div class="panel p-4 flex items-center gap-3">
<span class="text-xs font-mono flex-shrink-0" style="color:var(--text-muted)">КОНЕЧНЫЙ URL</span>
<span id="finalUrlText" class="url-text"></span>
<button class="copy-btn" id="copyFinalUrl">Копировать</button>
</div>
<!-- Issues -->
<div id="issuesBlock" class="hidden space-y-2">
<p class="text-xs font-mono font-semibold uppercase tracking-wider" style="color:var(--text-muted)">SEO Проблемы</p>
<div id="issuesList" class="space-y-2"></div>
</div>
<!-- Chain -->
<div>
<p class="text-xs font-mono font-semibold uppercase tracking-wider mb-3" style="color:var(--text-muted)">Цепочка редиректов</p>
<div id="chainContainer" class="space-y-1"></div>
</div>
<!-- Headers detail -->
<details class="details-accordion" id="headersAccordion">
<summary>
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/></svg>
Детальные заголовки ответов
<svg class="ml-auto" width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
</summary>
<div class="details-body">
<div id="headersDetail" class="overflow-x-auto"></div>
</div>
</details>
<!-- Export -->
<div class="flex flex-wrap gap-2">
<button class="export-btn" id="btnCopyJson">
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"/></svg>
Копировать JSON
</button>
<button class="export-btn" id="btnDownloadJson">
<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="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>
Скачать JSON
</button>
<button class="export-btn" id="btnDownloadCsv">
<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="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>
Скачать CSV
</button>
</div>
</div><!-- /stateResult -->
</div>
</div>
<script>
(function() {
// Theme
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
// State
let lastResult = null;
// UI refs
const urlInput = document.getElementById('urlInput');
const uaSelect = document.getElementById('uaSelect');
const methodSel = document.getElementById('methodSelect');
const analyzeBtn = document.getElementById('analyzeBtn');
const btnIcon = document.getElementById('btnIcon');
function showState(name) {
['Empty','Loading','Error','Result'].forEach(s => {
document.getElementById('state' + s).classList.toggle('hidden', s.toLowerCase() !== name);
});
}
function statusClass(code) {
if (code >= 500) return 's-5xx';
if (code >= 400) return 's-4xx';
if (code >= 300) return 's-3xx';
return 's-2xx';
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderChain(data) {
const container = document.getElementById('chainContainer');
container.innerHTML = '';
const chain = data.chain;
chain.forEach((step, i) => {
const isFinal = (i === chain.length - 1);
const isRedirect = step.status >= 300 && step.status < 400;
const sc = statusClass(step.status);
const card = document.createElement('div');
card.className = 'chain-step';
const cardHtml = `
<div class="step-card ${isFinal ? 'final' : ''}">
<div class="flex items-start gap-3">
<div class="step-num ${isFinal ? 'final' : ''}">${i + 1}</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="status-badge ${sc}">${escHtml(step.status)} ${escHtml(step.statusText)}</span>
${isFinal ? '<span class="final-badge"><svg width="10" height="10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>ФИНАЛ</span>' : ''}
<span class="text-xs font-mono ml-auto" style="color:var(--text-muted)">${step.time}ms</span>
</div>
<div class="url-text"><a href="${escHtml(step.url)}" target="_blank" rel="noopener">${escHtml(step.url)}</a></div>
${step.location ? `<div class="text-xs font-mono mt-1" style="color:var(--text-muted)">Location: <span style="color:#6b9fff">${escHtml(step.location)}</span></div>` : ''}
</div>
</div>
</div>`;
card.innerHTML = cardHtml;
container.appendChild(card);
// Arrow between steps
if (!isFinal && i < chain.length - 1) {
const next = chain[i + 1];
const arrow = document.createElement('div');
arrow.className = 'arrow-connector';
arrow.innerHTML = `
<div class="arrow-line"></div>
<span class="status-badge ${sc}">${escHtml(step.status)}</span>
<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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3"/></svg>
<div class="arrow-line"></div>`;
container.appendChild(arrow);
}
});
}
function renderSummary(data) {
const redirectCount = data.chain.filter(s => s.status >= 300 && s.status < 400).length;
document.getElementById('summaryBlock').innerHTML = `
<div class="stat-card">
<div class="stat-val">${redirectCount}</div>
<div class="stat-label">Редиректов</div>
</div>
<div class="stat-card">
<div class="stat-val">${data.total_time}ms</div>
<div class="stat-label">Общее время</div>
</div>
<div class="stat-card">
<div class="stat-val">${data.chain.length}</div>
<div class="stat-label">Всего шагов</div>
</div>
<div class="stat-card">
<div class="stat-val ${data.loop_detected ? 'text-red-400' : ''}">${data.loop_detected ? 'ПЕТЛЯ' : 'OK'}</div>
<div class="stat-label">Петля</div>
</div>`;
}
function renderIssues(data) {
const issuesBlock = document.getElementById('issuesBlock');
const issuesList = document.getElementById('issuesList');
const issues = data.issues || [];
const hasLoop = data.loop_detected;
const messages = [];
if (hasLoop) {
messages.push({ cls: 'issue-error', icon: '&#10060;', text: 'Петля редиректов — URL уже встречался в цепочке' });
}
if (issues.includes('long_chain')) {
const n = data.chain.filter(s => s.status >= 300 && s.status < 400).length;
messages.push({ cls: 'issue-warn', icon: '&#9888;', text: `Chain too long — ${n} редиректов (рекомендуется не более 3)` });
}
if (issues.includes('mixed_protocol')) {
messages.push({ cls: 'issue-warn', icon: '&#9888;', text: 'Небезопасный редирект — переход с HTTPS на HTTP' });
}
if (issues.includes('302_not_301')) {
messages.push({ cls: 'issue-info', icon: '&#8505;', text: '302 вместо 301 — временный редирект не передаёт PageRank' });
}
if (messages.length === 0) {
issuesBlock.classList.add('hidden');
return;
}
issuesBlock.classList.remove('hidden');
issuesList.innerHTML = messages.map(m =>
`<div class="issue-item ${m.cls}"><span>${m.icon}</span><span>${m.text}</span></div>`
).join('');
}
function renderHeaders(data) {
const container = document.getElementById('headersDetail');
const rows = data.chain.map((step, i) => {
const hdrs = Object.entries(step.headers || {});
const hdrRows = hdrs.map(([k,v]) => `<tr><td>${escHtml(k)}</td><td>${escHtml(v)}</td></tr>`).join('');
return `
<div class="px-4 pt-3 pb-1">
<p class="text-xs font-mono font-semibold mb-2" style="color:var(--text-muted)">Шаг ${i+1} — ${escHtml(step.status)} ${escHtml(step.url.length > 60 ? step.url.substring(0,60)+'...' : step.url)}</p>
<table class="headers-table mb-3">
<thead><tr><th>Заголовок</th><th>Значение</th></tr></thead>
<tbody>${hdrRows || '<tr><td colspan="2" style="color:var(--text-muted)">— нет заголовков —</td></tr>'}</tbody>
</table>
</div>`;
}).join('<hr style="border-color:var(--surface-600);margin:0">');
container.innerHTML = rows;
}
async function analyze() {
let url = urlInput.value.trim();
if (!url) return;
// Auto-add protocol
if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
analyzeBtn.disabled = true;
btnIcon.innerHTML = '<circle cx="12" cy="12" r="10" stroke="#fff" stroke-width="2" fill="none" stroke-dasharray="32" stroke-dashoffset="32" style="animation:spin .8s linear infinite"/>';
showState('loading');
try {
const resp = await fetch('/api/redirect-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, userAgent: uaSelect.value, method: methodSel.value }),
});
const data = await resp.json();
if (!resp.ok || data.error) {
document.getElementById('errorMsg').textContent = data.error || `HTTP ${resp.status}`;
showState('error');
return;
}
lastResult = data;
// Final URL
const finalUrl = data.final_url || url;
document.getElementById('finalUrlText').innerHTML = `<a href="${escHtml(finalUrl)}" target="_blank" rel="noopener">${escHtml(finalUrl)}</a>`;
renderSummary(data);
renderIssues(data);
renderChain(data);
renderHeaders(data);
showState('result');
} catch (err) {
document.getElementById('errorMsg').textContent = err.message;
showState('error');
} finally {
analyzeBtn.disabled = false;
btnIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 15.803a7.5 7.5 0 0010.607 0z"/>';
}
}
// Events
analyzeBtn.addEventListener('click', analyze);
urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') analyze(); });
// Copy final URL
document.getElementById('copyFinalUrl').addEventListener('click', () => {
if (!lastResult) return;
navigator.clipboard.writeText(lastResult.final_url || '').then(() => {
const btn = document.getElementById('copyFinalUrl');
btn.textContent = 'Скопировано!';
setTimeout(() => btn.textContent = 'Копировать', 1500);
});
});
// Export
document.getElementById('btnCopyJson').addEventListener('click', () => {
if (!lastResult) return;
navigator.clipboard.writeText(JSON.stringify(lastResult, null, 2)).then(() => {
const btn = document.getElementById('btnCopyJson');
btn.innerHTML = btn.innerHTML.replace('Копировать JSON','Скопировано!');
setTimeout(() => btn.innerHTML = btn.innerHTML.replace('Скопировано!','Копировать JSON'), 1500);
});
});
document.getElementById('btnDownloadJson').addEventListener('click', () => {
if (!lastResult) return;
const blob = new Blob([JSON.stringify(lastResult, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'redirect-chain.json';
a.click();
});
document.getElementById('btnDownloadCsv').addEventListener('click', () => {
if (!lastResult) return;
const rows = [['Step','URL','Status','StatusText','Location','Time(ms)']];
lastResult.chain.forEach((s, i) => {
rows.push([i+1, s.url, s.status, s.statusText, s.location||'', s.time]);
});
const csv = rows.map(r => r.map(c => '"'+String(c).replace(/"/g,'""')+'"').join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'redirect-chain.csv';
a.click();
});
// Pre-fill from query param
const qp = new URLSearchParams(location.search);
if (qp.get('url')) { urlInput.value = qp.get('url'); analyze(); }
})();
</script>
</body>
</html>

309
public/sanitizer.html Normal file
View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTML Sanitizer — images.wadevelop.ru</title>
<script src="/vendor/tailwind.js"></script>
<link href="/vendor/fonts.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' },
warn: '#ffd600',
danger: '#ff5252',
}
}
}
}
</script>
<style>
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:50;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px);transition:background .3s,border-color .3s}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:#0054e6;background:rgba(0,84,230,.08)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:#0054e6}
.sidebar-bottom{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:8px}
.main-content{margin-left:56px}
@media(max-width:640px){
.sidebar{position:fixed;top:auto;bottom:0;left:0;width:100%;height:52px;flex-direction:row;justify-content:center;padding:0 16px;border-right:none;border-top:1px solid var(--surface-600);gap:0}
.sidebar-logo{display:none}
.sidebar-nav{flex-direction:row;gap:4px;flex:none}
.sidebar-bottom{margin-top:0;margin-left:auto;flex-direction:row}
.sidebar-link.active::before{left:50%;top:auto;bottom:-6px;transform:translateX(-50%);width:20px;height:3px;border-radius:3px 3px 0 0}
.main-content{margin-left:0;padding-bottom:60px}
}
:root{--bg:#f5f5f7;--bg-grad1:rgba(0,67,184,.04);--bg-grad2:rgba(0,150,60,.03);--surface-800:rgba(255,255,255,0.85);--surface-700:#f0f0f3;--surface-600:#dde0e6;--text-primary:#1a1a2e;--text-secondary:#555;--text-muted:#888;--select-bg:#f0f0f3;--select-color:#1a1a2e}
.dark{--bg:#060c18;--bg-grad1:rgba(0,84,230,.03);--bg-grad2:rgba(0,67,184,.02);--surface-800:rgba(9,16,32,0.8);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--select-bg:#091020;--select-color:#e0e0e0}
body{background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,var(--bg-grad1) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,var(--bg-grad2) 0%,transparent 50%);min-height:100vh;transition:background .3s}
.dark .noise::before{content:'';position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");pointer-events:none;z-index:0}
.theme-toggle{position:relative;width:44px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s}
.dark .theme-toggle{background:#162040}.theme-toggle{background:#dde0e6}
.theme-toggle-knob{position:absolute;top:2px;width:20px;height:20px;border-radius:50%;transition:left .3s,background .3s;display:flex;align-items:center;justify-content:center}
.dark .theme-toggle-knob{left:22px;background:#0d1828}.theme-toggle-knob{left:2px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
select,select option{background:var(--select-bg);color:var(--select-color)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
.sub-btn.active{color:#0054e6;background:rgba(0,84,230,.12);border-color:rgba(0,84,230,.3)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<nav class="sidebar">
<div class="sidebar-logo">WA</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></a>
<a href="/compress" class="sidebar-link" title="Картинки"><svg width="22" height="22" 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></a>
<a href="/md" class="sidebar-link" title="Markdown"><svg width="22" height="22" 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></a>
<a href="/placeholder" class="sidebar-link" title="Placeholder"><svg width="22" height="22" 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></a>
<a href="/parser" class="sidebar-link" title="Parser"><svg width="22" height="22" 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></a>
<a href="/password" class="sidebar-link" title="Password"><svg width="22" height="22" 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></a>
<a href="/sanitizer" class="sidebar-link active" title="Sanitizer"><svg width="22" height="22" 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></a>
<a href="/converter" class="sidebar-link" title="Converter"><svg width="22" height="22" 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></a>
<a href="/formatter" class="sidebar-link" title="Formatter"><svg width="22" height="22" 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></a>
<a href="/editor" class="sidebar-link" title="Editor"><svg width="22" height="22" 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></a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client">
<svg width="22" height="22" 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>
</a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer">
<svg width="22" height="22" 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>
</a>
<a href="/svgeditor" class="sidebar-link" title="SVG Редактор">
<svg width="22" height="22" 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>
</a>
</div>
<div class="sidebar-bottom">
<div class="theme-toggle" id="themeToggle" title="Переключить тему">
<div class="theme-toggle-knob">
<svg class="w-3 h-3 dark:hidden text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/></svg>
<svg class="w-3 h-3 hidden dark:block text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</div>
</div>
</div>
</nav>
<div class="main-content">
<div class="relative z-10 max-w-3xl mx-auto px-4 py-8 sm:py-12">
<div class="text-center mb-8 fade-up">
<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>
XSS Protection &middot; Client-side
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
HTML <span class="text-accent">Sanitizer</span>
</h1>
<p class="mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">очистка, извлечение текста, entities, минификация</p>
</div>
<!-- Tool Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-5 fade-up-delay">
<button class="tool-btn active px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="sanitize">
<div class="text-xs font-semibold">Sanitize</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">XSS cleanup</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="htmlToText">
<div class="text-xs font-semibold">HTML &rarr; Text</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">strip tags</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="entities">
<div class="text-xs font-semibold">Entities</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">encode / decode</div>
</button>
<button class="tool-btn px-3 py-2.5 rounded-xl text-center border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 cursor-pointer" data-tool="minify">
<div class="text-xs font-semibold">Minify</div>
<div class="text-[10px] dark:text-gray-600 text-gray-400 mt-0.5">compress html</div>
</button>
</div>
<!-- Processing 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 id="entityOpts" class="flex gap-2 mb-4 hidden">
<button class="sub-btn active flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors cursor-pointer" data-sub="encode">Encode</button>
<button class="sub-btn flex-1 px-3 py-2 rounded-lg text-xs font-medium border dark:border-surface-600 border-gray-200 dark:text-gray-400 text-gray-500 transition-colors cursor-pointer" data-sub="decode">Decode</button>
</div>
<div class="mb-4">
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Input</label>
<textarea id="inputArea" rows="8" placeholder="Вставьте HTML для очистки..."
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none focus:border-accent/50 transition-colors"></textarea>
</div>
<button id="processBtn"
class="w-full py-3 rounded-xl font-semibold text-sm tracking-wide transition-all duration-300
bg-accent text-white border border-accent cursor-pointer
hover:bg-accent-bright hover:shadow-lg hover:shadow-accent/20 active:scale-[0.98]">
Очистить
</button>
<div id="outputBlock" class="mt-5 hidden">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium dark:text-gray-500 text-gray-400 font-mono uppercase tracking-wider">Output</label>
<div class="flex items-center gap-2">
<span id="stats" class="text-[10px] font-mono dark:text-gray-600 text-gray-400"></span>
<button id="copyBtn" class="px-3 py-1 rounded-lg text-xs font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors border border-accent/20">Копировать</button>
<button id="clearBtn" class="px-3 py-1 rounded-lg text-xs font-medium dark:bg-surface-700 bg-gray-100 dark:text-gray-400 text-gray-500 hover:text-danger transition-colors border dark:border-surface-600 border-gray-200">Очистить</button>
</div>
</div>
<textarea id="outputArea" rows="10" readonly
class="w-full px-4 py-3 rounded-xl dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 dark:text-gray-200 text-gray-700 focus:outline-none transition-colors"></textarea>
</div>
</div>
</div>
</div>
<script>
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) { html.className = savedTheme; }
else if (!window.matchMedia('(prefers-color-scheme: dark)').matches) { html.className = ''; }
document.getElementById('themeToggle').addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : '');
});
const TOOLS = {
sanitize: { btn: 'Очистить', ph: 'Вставьте HTML для очистки...' },
htmlToText: { btn: 'Извлечь текст', ph: 'Вставьте HTML...' },
entities: { btn: 'Конвертировать', ph: 'Вставьте текст или HTML entities...', hasSub: true },
minify: { btn: 'Минифицировать', ph: 'Вставьте HTML...' },
};
let activeTool = 'sanitize';
let entityMode = 'encode';
const $ = id => document.getElementById(id);
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeTool = btn.dataset.tool;
const t = TOOLS[activeTool];
$('processBtn').textContent = t.btn;
$('inputArea').placeholder = t.ph;
$('entityOpts').classList.toggle('hidden', !t.hasSub);
});
});
document.querySelectorAll('.sub-btn[data-sub]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sub-btn[data-sub]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
entityMode = btn.dataset.sub;
});
});
$('processBtn').addEventListener('click', () => {
const input = $('inputArea').value;
if (!input.trim()) return;
let output = '';
try {
switch (activeTool) {
case 'sanitize': output = sanitizeHtml(input); break;
case 'htmlToText': output = htmlToText(input); break;
case 'entities': output = entityMode === 'encode' ? entityEncode(input) : entityDecode(input); break;
case 'minify': output = minifyHtml(input); break;
}
} catch (e) { output = 'Error: ' + e.message; }
$('outputArea').value = output;
$('outputBlock').classList.remove('hidden');
const diff = output.length - input.length;
$('stats').textContent = `${input.length} \u2192 ${output.length} (${diff > 0 ? '+' : ''}${diff})`;
});
$('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText($('outputArea').value).then(() => {
const b = $('copyBtn'); const o = b.textContent;
b.textContent = '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!'; setTimeout(() => { b.textContent = o; }, 1500);
});
});
$('clearBtn').addEventListener('click', () => {
$('inputArea').value = ''; $('outputArea').value = ''; $('outputBlock').classList.add('hidden');
});
// ==================== Sanitize ====================
function sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const SAFE = new Set(['p','br','hr','b','i','em','strong','u','s','del','ins','a','img','ul','ol','li','dl','dt','dd','h1','h2','h3','h4','h5','h6','blockquote','pre','code','kbd','samp','var','table','thead','tbody','tfoot','tr','th','td','caption','figure','figcaption','span','div','section','article','aside','header','footer','main','nav','sub','sup','mark','small','abbr','time','details','summary']);
const ATTRS = new Set(['href','src','alt','title','class','id','width','height','target','rel','colspan','rowspan','datetime','lang','dir','loading','srcset','sizes']);
const KILL = new Set(['script','style','iframe','object','embed','applet','form','input','textarea','select','button','link','meta','base','noscript']);
(function clean(parent) {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType !== 1) continue;
const tag = node.tagName.toLowerCase();
if (KILL.has(tag)) { node.remove(); continue; }
if (!SAFE.has(tag)) {
while (node.firstChild) parent.insertBefore(node.firstChild, node);
node.remove(); continue;
}
for (const attr of Array.from(node.attributes)) {
if (attr.name.startsWith('on') || !ATTRS.has(attr.name)) { node.removeAttribute(attr.name); continue; }
if ((attr.name === 'href' || attr.name === 'src') && /^(javascript|vbscript|data:text\/html)/i.test(attr.value.trim())) {
node.removeAttribute(attr.name);
}
}
if (tag === 'a' && node.getAttribute('target')) node.setAttribute('rel', 'noopener noreferrer');
clean(node);
}
})(doc.body);
return doc.body.innerHTML;
}
// ==================== HTML → Text ====================
function htmlToText(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1) return '';
const tag = node.tagName.toLowerCase();
if (tag === 'script' || tag === 'style') return '';
const inner = Array.from(node.childNodes).map(walk).join('');
switch (tag) {
case 'br': return '\n';
case 'hr': return '\n---\n';
case 'p': case 'div': case 'section': case 'article': return '\n\n' + inner.trim() + '\n\n';
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return '\n\n' + inner.trim() + '\n\n';
case 'li': return '\n - ' + inner.trim();
case 'ul': case 'ol': return '\n' + inner + '\n';
case 'blockquote': return '\n> ' + inner.trim().split('\n').join('\n> ') + '\n';
case 'pre': return '\n\n' + node.textContent + '\n\n';
case 'tr': return inner + '\n';
case 'td': case 'th': return inner + '\t';
default: return inner;
}
}
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
}
// ==================== Entities ====================
function entityEncode(text) {
return text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function entityDecode(text) {
const el = document.createElement('textarea');
el.innerHTML = text;
return el.value;
}
// ==================== Minify ====================
function minifyHtml(html) {
return html.replace(/<!--[\s\S]*?-->/g,'').replace(/\s+/g,' ').replace(/>\s+</g,'><').replace(/\s+>/g,'>').replace(/<\s+/g,'<').trim();
}
</script>
</body>
</html>

2455
public/svgeditor.html Normal file

File diff suppressed because it is too large Load Diff

63
public/vendor/fonts.css vendored Normal file
View File

@ -0,0 +1,63 @@
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/vendor/fonts/jetbrains-400.ttf) format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/vendor/fonts/jetbrains-500.ttf) format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/vendor/fonts/jetbrains-600.ttf) format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/vendor/fonts/jetbrains-700.ttf) format('truetype');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/vendor/fonts/manrope-400.ttf) format('truetype');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/vendor/fonts/manrope-500.ttf) format('truetype');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/vendor/fonts/manrope-600.ttf) format('truetype');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/vendor/fonts/manrope-700.ttf) format('truetype');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(/vendor/fonts/manrope-800.ttf) format('truetype');
}

BIN
public/vendor/fonts/jetbrains-400.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/jetbrains-500.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/jetbrains-600.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/jetbrains-700.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/manrope-400.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/manrope-500.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/manrope-600.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/manrope-700.ttf vendored Normal file

Binary file not shown.

BIN
public/vendor/fonts/manrope-800.ttf vendored Normal file

Binary file not shown.

1213
public/vendor/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

10
public/vendor/hljs-github-dark.css vendored Normal file
View File

@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

10
public/vendor/hljs-github.css vendored Normal file
View File

@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

69
public/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
public/vendor/opentype.min.js vendored Normal file

File diff suppressed because one or more lines are too long

83
public/vendor/tailwind.js vendored Normal file

File diff suppressed because one or more lines are too long

1111
server.js Executable file

File diff suppressed because it is too large Load Diff