mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce1b20e85 | ||
|
|
31ace359d7 | ||
|
|
fbe6139585 | ||
|
|
2f11cb86ad | ||
|
|
676be383d9 | ||
|
|
32cb369ca9 | ||
|
|
557c74b26e | ||
|
|
b7be973b54 | ||
|
|
47f42f5de3 | ||
|
|
1ba03b731d | ||
|
|
5c58451f2f | ||
|
|
e7d3925eb2 |
@@ -27,7 +27,7 @@ jobs:
|
|||||||
id: generate_token
|
id: generate_token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v1
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.XBOCT_BOT_ID }}
|
app-id: ${{ vars.XBOCT_BOT_ID }}
|
||||||
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
|
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
||||||
|
|
||||||
- - -
|
- - -
|
||||||
|
## [v0.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07
|
||||||
|
#### Features
|
||||||
|
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
|
||||||
|
- add socials icons - ([fbe6139](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fbe6139585aeaca2b70b55224115c9e2b7653ca6)) - Ku6epXBOCTuK
|
||||||
|
- add favorites tools - ([2f11cb8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f11cb86ade08b91f48c823f3354f62260bae262)) - Ku6epXBOCTuK
|
||||||
|
- add split to parts by tile size - ([32cb369](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/32cb369ca96a96fc0194a63d8086db610f3f190a)) - Ku6epXBOCTuK
|
||||||
|
- aligned mode for pipeline - ([b7be973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7be973b54091b071e0c751533a73ad1d0ea11fb)) - Ku6epXBOCTuK
|
||||||
|
- change tool inplace - ([1ba03b7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ba03b731d80212eedcf9d8985478dc78e2e32f4)) - Ku6epXBOCTuK
|
||||||
|
- collapse\expand steps in pipeline - ([5c58451](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c58451f2f5a0dde167d7b2bebcc8f3190f20212)) - Ku6epXBOCTuK
|
||||||
|
#### Bug Fixes
|
||||||
|
- select mode via segmented button in split by parts - ([676be38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/676be383d9fb71b7dc4e83d8c243dc39c60f6c36)) - Ku6epXBOCTuK
|
||||||
|
- add text to no param tools - ([e7d3925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e7d3925eb2063b0265a396bf693b4ed95b49782b)) - Ku6epXBOCTuK
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
|
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
|
||||||
#### Features
|
#### Features
|
||||||
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
|
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
|
||||||
|
|||||||
+23
-24
@@ -15,14 +15,14 @@
|
|||||||
страницы — возможно, надо указать все форматы (связано с SEO)
|
страницы — возможно, надо указать все форматы (связано с SEO)
|
||||||
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
|
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
|
||||||
поиском, фильтром возможных инструментов и т.д.
|
поиском, фильтром возможных инструментов и т.д.
|
||||||
|
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
||||||
|
режима aligned\normal
|
||||||
|
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
||||||
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
|
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
|
||||||
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
|
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
|
||||||
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
|
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
|
||||||
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
|
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
|
||||||
(text-source ↔ file).
|
(text-source ↔ file).
|
||||||
- [ ] **Идея 2. Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания
|
|
||||||
звена до заголовка «Шаг n: название» (превью скрываются). Состояние
|
|
||||||
свёрнутости помнить в workspace-pipeline.
|
|
||||||
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||||
Именованные цепочки в localStorage (список, создать/переименовать/
|
Именованные цепочки в localStorage (список, создать/переименовать/
|
||||||
удалить), быстрое переключение. Формат экспортного JSON расширить
|
удалить), быстрое переключение. Формат экспортного JSON расширить
|
||||||
@@ -57,23 +57,14 @@
|
|||||||
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
|
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
|
||||||
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
|
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
|
||||||
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
|
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
|
||||||
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
|
|
||||||
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
|
|
||||||
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
|
|
||||||
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
|
|
||||||
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
|
|
||||||
исходник и результат построчно «параметры — исходник — результат»;
|
|
||||||
промежуточные картинки при этом осознанно дублируются на экране —
|
|
||||||
результат шага является исходником следующего.
|
|
||||||
|
|
||||||
## Инструменты
|
## Инструменты
|
||||||
|
|
||||||
|
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
|
||||||
|
такими делать вообще? оставить как seo? можно ли под капотом использовать
|
||||||
|
другой инструмент с хардкодед настройками?
|
||||||
- [ ] qr code to text, text to qr code
|
- [ ] qr code to text, text to qr code
|
||||||
- [ ] bar code to text, text to bar code
|
- [ ] bar code to text, text to bar code
|
||||||
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
|
||||||
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
|
||||||
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
|
||||||
схема + core, из задачи C3)
|
|
||||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||||
выделенной области
|
выделенной области
|
||||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||||
@@ -144,11 +135,13 @@
|
|||||||
|
|
||||||
## UI и качество
|
## UI и качество
|
||||||
|
|
||||||
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
||||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
удобнее чем sectioned buttons
|
||||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
|
||||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||||
|
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
||||||
|
мгновенно пересортировываются и под мышкой оказывается другой элемент.
|
||||||
|
Нужна или анимация, или механизм отложенного обновления позиций
|
||||||
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
|
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
|
||||||
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
|
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
|
||||||
черная\белая\цветные\серая для проверки прозрачных картинок
|
черная\белая\цветные\серая для проверки прозрачных картинок
|
||||||
@@ -185,11 +178,6 @@
|
|||||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||||
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
||||||
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
|
|
||||||
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
|
|
||||||
страницы инструмента; список slug в localStorage. Избранное показывается
|
|
||||||
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
|
|
||||||
popularity при скоринге).
|
|
||||||
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
||||||
`--color-main`/`--color-accent` подняты под текущий порог
|
`--color-main`/`--color-accent` подняты под текущий порог
|
||||||
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
||||||
@@ -239,7 +227,15 @@
|
|||||||
переименований уже опубликованных страниц; клонов с canonical не делать
|
переименований уже опубликованных страниц; клонов с canonical не делать
|
||||||
(`docs/plan-seo.md` §5)
|
(`docs/plan-seo.md` §5)
|
||||||
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
|
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
|
||||||
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
|
- [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента
|
||||||
|
своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента,
|
||||||
|
бренд, иконка. Сейчас все страницы ссылаются на общую
|
||||||
|
`static/og/default.png` (генерирует `pnpm --dir web gen:og` —
|
||||||
|
Playwright-скриншот HTML-карточки в `web/scripts/gen-og.mjs`). Что делать:
|
||||||
|
расширить gen-og.mjs — проход по реестру страниц, карточка с названием
|
||||||
|
инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по
|
||||||
|
slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя
|
||||||
|
картинка с названием инструмента, файл лежит в `static/og/`
|
||||||
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
|
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
|
||||||
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
|
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
|
||||||
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
|
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
|
||||||
@@ -250,6 +246,9 @@
|
|||||||
|
|
||||||
## Платформа (API/PWA)
|
## Платформа (API/PWA)
|
||||||
|
|
||||||
|
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
|
||||||
|
популярности инструментов через Vercel KV (serverless-функция: INCR при
|
||||||
|
открытии инструмента, чтение топа для скоринга поиска).
|
||||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
||||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||||
(после wasm-фаз 5–7)
|
(после wasm-фаз 5–7)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "easy-png-tools",
|
"name": "easy-png-tools",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:rules": "vitest run eslint-plugins/__tests__",
|
"test:rules": "vitest run eslint-plugins/__tests__",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"gen:og": "node scripts/gen-og.mjs",
|
||||||
"format": "prettier --write . --log-level warn --cache",
|
"format": "prettier --write . --log-level warn --cache",
|
||||||
"format:check": "prettier --check . --log-level warn --cache",
|
"format:check": "prettier --check . --log-level warn --cache",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"@lucide/svelte": "^1.33.0",
|
"@lucide/svelte": "^1.33.0",
|
||||||
"fflate": "^0.8.3"
|
"fflate": "^0.8.3",
|
||||||
|
"simple-icons": "^16.34.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9
@@ -20,6 +20,9 @@ importers:
|
|||||||
fflate:
|
fflate:
|
||||||
specifier: ^0.8.3
|
specifier: ^0.8.3
|
||||||
version: 0.8.3
|
version: 0.8.3
|
||||||
|
simple-icons:
|
||||||
|
specifier: ^16.34.0
|
||||||
|
version: 16.34.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@csstools/postcss-global-data':
|
'@csstools/postcss-global-data':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
@@ -1387,6 +1390,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
simple-icons@16.34.0:
|
||||||
|
resolution: {integrity: sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==}
|
||||||
|
engines: {node: '>=0.12.18'}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2805,6 +2812,8 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
simple-icons@16.34.0: {}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@polka/url': 1.0.0-next.29
|
'@polka/url': 1.0.0-next.29
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Генерирует static/og/default.png — дефолтная OG-карточка 1200×630.
|
||||||
|
// Запуск: pnpm --dir web gen:og. Карточка рисуется в браузере (текст, иконки),
|
||||||
|
// per-tool карточки — этим же harness позже (docs/plan-seo.md S1e).
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const W = 1200;
|
||||||
|
const H = 630;
|
||||||
|
|
||||||
|
const IMAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
|
||||||
|
|
||||||
|
function cardHtml() {
|
||||||
|
return `<!doctype html><html><body style="margin:0">
|
||||||
|
<div style="
|
||||||
|
width:${W}px;height:${H}px;box-sizing:border-box;
|
||||||
|
background:#0a120c;
|
||||||
|
background-image:linear-gradient(#12281a 1px, transparent 1px),linear-gradient(90deg,#12281a 1px, transparent 1px);
|
||||||
|
background-size:40px 40px;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:80px;font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;
|
||||||
|
border:3px solid #16a34a;
|
||||||
|
">
|
||||||
|
<div>
|
||||||
|
<div style="
|
||||||
|
width:72px;height:72px;background:#86efac;color:#052e16;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:34px;font-weight:700;margin-bottom:32px;
|
||||||
|
">EP</div>
|
||||||
|
<div style="color:#86efac;font-size:56px;font-weight:700;letter-spacing:-1px;">easy-png-tools</div>
|
||||||
|
<div style="color:#a7c4b1;font-size:28px;margin-top:24px;">PNG utilities right in your browser</div>
|
||||||
|
<div style="color:#4d7c5f;font-size:22px;margin-top:16px;">Convert · Crop · Resize — locally, no uploads</div>
|
||||||
|
</div>
|
||||||
|
<div style="
|
||||||
|
color:#86efac;width:300px;height:300px;border:3px solid #16a34a;
|
||||||
|
background:#0f1f14;display:flex;align-items:center;justify-content:center;
|
||||||
|
"><div style="width:240px;height:240px;">${IMAGE_ICON}</div></div>
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(new URL("../static/og/", import.meta.url), { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage({ viewport: { width: W, height: H } });
|
||||||
|
await page.setContent(cardHtml());
|
||||||
|
await page.screenshot({
|
||||||
|
path: new URL("../static/og/default.png", import.meta.url).pathname.replace(
|
||||||
|
/^\/([A-Za-z]:)/,
|
||||||
|
"$1",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
console.log(`og/default.png written (${W}x${H})`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SITE_URL } from "$lib/seo";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Путь страницы от корня роутера ("/", "/list-tools", "/tools/…"). */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
let { title, description, path }: Props = $props();
|
||||||
|
|
||||||
|
const url = $derived(`${SITE_URL}${path}`);
|
||||||
|
const image = `${SITE_URL}/og/default.png`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={url} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="easy-png-tools" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={url} />
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={image} />
|
||||||
|
</svelte:head>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { ArrowUpRight } from "@lucide/svelte";
|
import { ArrowUpRight, Star } from "@lucide/svelte";
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,6 +22,20 @@
|
|||||||
<strong>{title}</strong>
|
<strong>{title}</strong>
|
||||||
{#if description}<span>{description}</span>{/if}
|
{#if description}<span>{description}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-fav"
|
||||||
|
class:active={isFav(slug)}
|
||||||
|
aria-label={isFav(slug) ? t("ui.removeFav") : t("ui.addFav")}
|
||||||
|
aria-pressed={isFav(slug)}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFav(slug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star size={15} fill={isFav(slug) ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
<span class="tool-index">
|
<span class="tool-index">
|
||||||
{index !== undefined ? index.toString().padStart(2, "0") : ""}
|
{index !== undefined ? index.toString().padStart(2, "0") : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -31,7 +47,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
|
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
|
||||||
var(--size-tool-arrow);
|
var(--space-xxl) var(--size-tool-arrow);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
min-height: var(--size-tool-min-height);
|
min-height: var(--size-tool-min-height);
|
||||||
@@ -54,6 +70,19 @@
|
|||||||
color: var(--color-main);
|
color: var(--color-main);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
}
|
}
|
||||||
|
.tool-fav {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-s);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tool-fav:hover,
|
||||||
|
.tool-fav.active {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
.tool-copy {
|
.tool-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GripVertical, X } from "@lucide/svelte";
|
import { ChevronDown, ChevronRight, GripVertical, X } from "@lucide/svelte";
|
||||||
import IconButton from "$lib/components/ui/IconButton.svelte";
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
@@ -9,9 +9,11 @@
|
|||||||
title?: string;
|
title?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
tools?: Snippet;
|
tools?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
onremove?: () => void;
|
onremove?: () => void;
|
||||||
|
ontoggle?: () => void;
|
||||||
ondragstart?: (e: DragEvent) => void;
|
ondragstart?: (e: DragEvent) => void;
|
||||||
ondragover?: (e: DragEvent) => void;
|
ondragover?: (e: DragEvent) => void;
|
||||||
ondrop?: (e: DragEvent) => void;
|
ondrop?: (e: DragEvent) => void;
|
||||||
@@ -23,9 +25,11 @@
|
|||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
collapsed = false,
|
||||||
tools,
|
tools,
|
||||||
children,
|
children,
|
||||||
onremove,
|
onremove,
|
||||||
|
ontoggle,
|
||||||
ondragstart,
|
ondragstart,
|
||||||
ondragover,
|
ondragover,
|
||||||
ondrop,
|
ondrop,
|
||||||
@@ -54,6 +58,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="step-tools">
|
<div class="step-tools">
|
||||||
{#if tools}{@render tools()}{/if}
|
{#if tools}{@render tools()}{/if}
|
||||||
|
{#if ontoggle}
|
||||||
|
<IconButton
|
||||||
|
icon={collapsed ? ChevronRight : ChevronDown}
|
||||||
|
label={collapsed ? t("chain.expandStep") : t("chain.collapseStep")}
|
||||||
|
variant="clear"
|
||||||
|
size="s"
|
||||||
|
onclick={() => ontoggle()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if onremove}
|
{#if onremove}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={X}
|
icon={X}
|
||||||
@@ -65,7 +78,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{#if children}
|
{#if children && !collapsed}
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Control from "./Control.svelte";
|
||||||
|
import Segmented from "$lib/components/ui/Segmented.svelte";
|
||||||
|
import { optionLabel } from "$lib/i18n/schema-tool-strings";
|
||||||
|
import type { FieldSpec, SegmentedSpec } from "$lib/registry-schema";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: unknown;
|
||||||
|
spec: FieldSpec;
|
||||||
|
toolId: string;
|
||||||
|
fieldId: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
let { label, value, spec, toolId, fieldId, onchange }: Props = $props();
|
||||||
|
|
||||||
|
const sp = $derived(spec as SegmentedSpec);
|
||||||
|
const current = $derived(
|
||||||
|
typeof value === "string" && sp.options.some((o) => o.value === value)
|
||||||
|
? value
|
||||||
|
: sp.default,
|
||||||
|
);
|
||||||
|
const options = $derived(
|
||||||
|
sp.options.map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: optionLabel(toolId, fieldId, o.value, o.label),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Control {label}>
|
||||||
|
<Segmented {options} value={current} onchange={(v) => onchange?.(v)} />
|
||||||
|
</Control>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
|
import BrandIcon from "$lib/components/ui/BrandIcon.svelte";
|
||||||
import LangToggle from "$lib/components/ui/LangToggle.svelte";
|
import LangToggle from "$lib/components/ui/LangToggle.svelte";
|
||||||
import ThemeToggle from "$lib/components/ui/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ui/ThemeToggle.svelte";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { siGithub, siTelegram, siTwitch } from "simple-icons";
|
||||||
import TopNav from "./TopNav.svelte";
|
import TopNav from "./TopNav.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +26,33 @@
|
|||||||
</a>
|
</a>
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
|
<a
|
||||||
|
class="social"
|
||||||
|
href="https://github.com/Ku6epXBOCTuK/easy-png-tools"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t("ui.github")}
|
||||||
|
>
|
||||||
|
<BrandIcon path={siGithub.path} size={16} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="social"
|
||||||
|
href="https://t.me/Ku6epXBOCTuK_feed"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t("ui.telegram")}
|
||||||
|
>
|
||||||
|
<BrandIcon path={siTelegram.path} size={16} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="social"
|
||||||
|
href="https://www.twitch.tv/ku6epxboctuk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t("ui.twitch")}
|
||||||
|
>
|
||||||
|
<BrandIcon path={siTwitch.path} size={16} />
|
||||||
|
</a>
|
||||||
<ThemeToggle {theme} {ontoggle} />
|
<ThemeToggle {theme} {ontoggle} />
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -64,4 +94,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
.social {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.social:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,123 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
|
||||||
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { chainablePages } from "$lib/pipeline.svelte";
|
import ToolPickerButton from "./ToolPickerButton.svelte";
|
||||||
import type { Page } from "$lib/registry";
|
|
||||||
import { Plus } from "@lucide/svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onadd: (toolId: string) => void;
|
onadd: (toolId: string) => void;
|
||||||
}
|
}
|
||||||
let { onadd }: Props = $props();
|
let { onadd }: Props = $props();
|
||||||
|
|
||||||
const pages = chainablePages();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let root = $state<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
function choose(page: Page) {
|
|
||||||
onadd(page.steps[0].id);
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
if (!root?.contains(e.target as Node)) open = false;
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") open = false;
|
|
||||||
}
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
window.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="add-step" bind:this={root}>
|
<div class="add-step">
|
||||||
<button
|
<ToolPickerButton label={t("chain.addStep")} {onadd} />
|
||||||
type="button"
|
|
||||||
class="add-btn"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
onclick={() => (open = !open)}
|
|
||||||
>
|
|
||||||
<Icon icon={Plus} size={14} />
|
|
||||||
<span>{t("chain.addStep")}</span>
|
|
||||||
</button>
|
|
||||||
{#if open}
|
|
||||||
<div class="menu" role="menu">
|
|
||||||
{#each pages as page (page.slug)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="item"
|
|
||||||
role="menuitem"
|
|
||||||
onclick={() => choose(page)}
|
|
||||||
>
|
|
||||||
{pageTitle(page)}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.add-step {
|
.add-step {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin: var(--space-s) 0;
|
margin: var(--space-s) 0;
|
||||||
}
|
}
|
||||||
.add-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-m);
|
|
||||||
padding: var(--space-m) var(--space-l);
|
|
||||||
border: var(--size-border) solid var(--color-main);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
background: var(--color-main);
|
|
||||||
color: var(--color-background);
|
|
||||||
font: 600 var(--font-size-s) var(--font-mono);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.add-btn:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--space-s));
|
|
||||||
right: 0;
|
|
||||||
z-index: var(--z-dropdown);
|
|
||||||
min-width: calc(var(--space-xxxl) * 8);
|
|
||||||
max-height: calc(var(--space-xxxl) * 8);
|
|
||||||
overflow-y: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-s);
|
|
||||||
padding: var(--space-m);
|
|
||||||
border: var(--size-border) solid var(--color-border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
background: var(--color-panel);
|
|
||||||
}
|
|
||||||
.item {
|
|
||||||
padding: var(--space-m) var(--space-l);
|
|
||||||
border: var(--size-border) solid transparent;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.item:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
|
import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
|
||||||
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
||||||
import SelectControl from "$lib/components/fields/schema/SelectControl.svelte";
|
import SelectControl from "$lib/components/fields/schema/SelectControl.svelte";
|
||||||
|
import SegmentedControl from "$lib/components/fields/schema/SegmentedControl.svelte";
|
||||||
import TextControl from "$lib/components/fields/schema/TextControl.svelte";
|
import TextControl from "$lib/components/fields/schema/TextControl.svelte";
|
||||||
import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
|
import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
|
||||||
import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
|
import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"color-pair": ColorPairControl,
|
"color-pair": ColorPairControl,
|
||||||
colors: ColorsControl,
|
colors: ColorsControl,
|
||||||
select: SelectControl,
|
select: SelectControl,
|
||||||
|
segmented: SegmentedControl,
|
||||||
checkbox: CheckboxControl,
|
checkbox: CheckboxControl,
|
||||||
text: TextControl,
|
text: TextControl,
|
||||||
dimension: DimensionField,
|
dimension: DimensionField,
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
||||||
|
const isEmpty = $derived(Object.keys(schema.fields).length === 0);
|
||||||
|
|
||||||
function effectiveSpec(spec: FieldSpec): FieldSpec {
|
function effectiveSpec(spec: FieldSpec): FieldSpec {
|
||||||
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
|
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
|
||||||
@@ -82,9 +85,17 @@
|
|||||||
}
|
}
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHidden(spec: FieldSpec): boolean {
|
||||||
|
const rule = spec.visibleWhen;
|
||||||
|
return rule !== undefined && values[rule.field] !== rule.equals;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each layoutGroups as group (group.key)}
|
{#if isEmpty}
|
||||||
|
<p class="no-params">{t("paramsCard.noParams")}</p>
|
||||||
|
{:else}
|
||||||
|
{#each layoutGroups as group (group.key)}
|
||||||
{#if group.title}
|
{#if group.title}
|
||||||
<span class="group-title">{groupLabel(group.title)}</span>
|
<span class="group-title">{groupLabel(group.title)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -95,21 +106,24 @@
|
|||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{#each group.fields as id (id)}
|
{#each group.fields as id (id)}
|
||||||
{@const Control = FIELDS[schema.fields[id].spec.kind]}
|
{@const spec = schema.fields[id].spec}
|
||||||
|
{@const Control = FIELDS[spec.kind]}
|
||||||
|
{#if !isHidden(spec)}
|
||||||
<Control
|
<Control
|
||||||
label={fieldLabel(schema.fields[id], id)}
|
label={fieldLabel(schema.fields[id], id)}
|
||||||
value={values[id]}
|
value={values[id]}
|
||||||
spec={effectiveSpec(schema.fields[id].spec)}
|
spec={effectiveSpec(spec)}
|
||||||
{toolId}
|
{toolId}
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
{sourceDims}
|
{sourceDims}
|
||||||
onchange={(v, axis) => onchange(id, v, axis)}
|
onchange={(v, axis) => onchange(id, v, axis)}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="panel-foot">
|
<div class="panel-foot">
|
||||||
<Button
|
<Button
|
||||||
icon={RotateCcw}
|
icon={RotateCcw}
|
||||||
label={t("ui.reset")}
|
label={t("ui.reset")}
|
||||||
@@ -117,7 +131,8 @@
|
|||||||
size="s"
|
size="s"
|
||||||
onclick={onreset}
|
onclick={onreset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.group-title {
|
.group-title {
|
||||||
@@ -132,6 +147,11 @@
|
|||||||
.group-title:first-child {
|
.group-title:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
.no-params {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
.group-fields {
|
.group-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import SchemaActions from "./SchemaActions.svelte";
|
import SchemaActions from "./SchemaActions.svelte";
|
||||||
import SchemaResultTile from "./SchemaResultTile.svelte";
|
import SchemaResultTile from "./SchemaResultTile.svelte";
|
||||||
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||||
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
|
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||||
|
import { toDataUrl } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
@@ -26,6 +29,9 @@
|
|||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
stepResults?: (PixelImage | null)[];
|
||||||
|
aligned?: boolean;
|
||||||
|
ontogglealign?: () => void;
|
||||||
format?: OutputMime;
|
format?: OutputMime;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
alphaLoss?: boolean;
|
alphaLoss?: boolean;
|
||||||
@@ -51,6 +57,9 @@
|
|||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
running = false,
|
running = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
stepResults = [],
|
||||||
|
aligned = false,
|
||||||
|
ontogglealign,
|
||||||
format = "image/png",
|
format = "image/png",
|
||||||
quality = undefined,
|
quality = undefined,
|
||||||
alphaLoss = false,
|
alphaLoss = false,
|
||||||
@@ -71,6 +80,16 @@
|
|||||||
|
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="label">{t("resultCard.previewPanel")}</span>
|
<span class="label">{t("resultCard.previewPanel")}</span>
|
||||||
|
{#if stepResults.length > 1}
|
||||||
|
<label class="align-toggle">
|
||||||
|
<Toggle
|
||||||
|
checked={aligned}
|
||||||
|
label={t("chain.alignToggle")}
|
||||||
|
onchange={() => ontogglealign?.()}
|
||||||
|
/>
|
||||||
|
<span>{t("chain.alignToggle")}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
@@ -100,6 +119,17 @@
|
|||||||
{onrendertext}
|
{onrendertext}
|
||||||
{onupload}
|
{onupload}
|
||||||
/>
|
/>
|
||||||
|
{#each stepResults.slice(0, -1) as img, i (i)}
|
||||||
|
{#if img}
|
||||||
|
<PreviewTile
|
||||||
|
label={t("chain.stepResult", { n: i + 1 })}
|
||||||
|
viewMode="image"
|
||||||
|
dims={`${img.width} × ${img.height}`}
|
||||||
|
>
|
||||||
|
<img src={toDataUrl(img)} alt="" />
|
||||||
|
</PreviewTile>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
<SchemaResultTile
|
<SchemaResultTile
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{result}
|
{result}
|
||||||
@@ -118,7 +148,7 @@
|
|||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
padding-bottom: var(--space-l);
|
padding-bottom: var(--space-l);
|
||||||
border-bottom: var(--size-border) solid var(--color-border);
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
@@ -130,6 +160,15 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-main);
|
color: var(--color-main);
|
||||||
}
|
}
|
||||||
|
.align-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
margin: 0 0 var(--space-l);
|
margin: 0 0 var(--space-l);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AddStepButton from "./AddStepButton.svelte";
|
|
||||||
import SchemaFields from "./SchemaFields.svelte";
|
|
||||||
import SchemaPreview from "./SchemaPreview.svelte";
|
|
||||||
import StepCard from "$lib/components/display/StepCard.svelte";
|
import StepCard from "$lib/components/display/StepCard.svelte";
|
||||||
|
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||||
|
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||||
|
import { Star } from "@lucide/svelte";
|
||||||
import { hasTransparency } from "$lib/core/analyze";
|
import { hasTransparency } from "$lib/core/analyze";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
downloadBlob,
|
downloadBlob,
|
||||||
encode,
|
encode,
|
||||||
outputFormatByMime,
|
outputFormatByMime,
|
||||||
|
toDataUrl,
|
||||||
type OutputMime,
|
type OutputMime,
|
||||||
} from "$lib/core/io";
|
} from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import {
|
import {
|
||||||
createChain,
|
createChain,
|
||||||
|
createStep,
|
||||||
insertStep,
|
insertStep,
|
||||||
loadChain,
|
loadChain,
|
||||||
moveStep,
|
moveStep,
|
||||||
@@ -49,6 +51,14 @@
|
|||||||
import { downloadZip } from "$lib/zip";
|
import { downloadZip } from "$lib/zip";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
|
import AddStepButton from "./AddStepButton.svelte";
|
||||||
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
|
import SchemaActions from "./SchemaActions.svelte";
|
||||||
|
import SchemaFields from "./SchemaFields.svelte";
|
||||||
|
import SchemaPreview from "./SchemaPreview.svelte";
|
||||||
|
import SchemaResultTile from "./SchemaResultTile.svelte";
|
||||||
|
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||||
|
import ToolPickerButton from "./ToolPickerButton.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: Page;
|
page: Page;
|
||||||
@@ -72,6 +82,8 @@
|
|||||||
// Последняя правленая ось dimension-поля — ведущая при lockAspect.
|
// Последняя правленая ось dimension-поля — ведущая при lockAspect.
|
||||||
const lastAxis: Record<string, "width" | "height"> = {};
|
const lastAxis: Record<string, "width" | "height"> = {};
|
||||||
let stepDims = $state<(Dimension | undefined)[]>([]);
|
let stepDims = $state<(Dimension | undefined)[]>([]);
|
||||||
|
let stepResults = $state<(PixelImage | null)[]>([]);
|
||||||
|
let aligned = $state(false);
|
||||||
let source = $state<PixelImage | null>(null);
|
let source = $state<PixelImage | null>(null);
|
||||||
let result = $state<PixelImage | null>(null);
|
let result = $state<PixelImage | null>(null);
|
||||||
let fileResult = $state<FileResult | null>(null);
|
let fileResult = $state<FileResult | null>(null);
|
||||||
@@ -95,6 +107,9 @@
|
|||||||
const resultKind = $derived(lastTool.result ?? "image");
|
const resultKind = $derived(lastTool.result ?? "image");
|
||||||
// Цепочку можно продолжить, только если последний шаг отдаёт картинку.
|
// Цепочку можно продолжить, только если последний шаг отдаёт картинку.
|
||||||
const canExtend = $derived((lastTool.result ?? "image") === "image");
|
const canExtend = $derived((lastTool.result ?? "image") === "image");
|
||||||
|
const alignedMode = $derived(
|
||||||
|
aligned && resultKind === "image" && steps.length > 1,
|
||||||
|
);
|
||||||
|
|
||||||
const alphaLoss = $derived(
|
const alphaLoss = $derived(
|
||||||
resultKind === "image" &&
|
resultKind === "image" &&
|
||||||
@@ -240,6 +255,26 @@
|
|||||||
steps = removeStep(steps, key);
|
steps = removeStep(steps, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleStep(index: number) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step) return;
|
||||||
|
steps = steps.with(index, { ...step, collapsed: !step.collapsed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Замена инструмента на месте: ключ и свёрнутость шага сохраняются,
|
||||||
|
// параметры — дефолты нового инструмента, вход пересчитывается при прогоне.
|
||||||
|
function replaceStepTool(index: number, toolId: string) {
|
||||||
|
const step = steps[index];
|
||||||
|
const next = createStep(toolId);
|
||||||
|
if (!step || !next) return;
|
||||||
|
touchedFor(step.key).clear();
|
||||||
|
steps = steps.with(index, {
|
||||||
|
...next,
|
||||||
|
key: step.key,
|
||||||
|
collapsed: step.collapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onStepDrop(e: DragEvent, to: number) {
|
function onStepDrop(e: DragEvent, to: number) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
|
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
|
||||||
@@ -305,6 +340,7 @@
|
|||||||
try {
|
try {
|
||||||
let current: PixelImage | undefined = source ?? undefined;
|
let current: PixelImage | undefined = source ?? undefined;
|
||||||
const dims: (Dimension | undefined)[] = [];
|
const dims: (Dimension | undefined)[] = [];
|
||||||
|
const results: (PixelImage | null)[] = [];
|
||||||
let out: ToolResult = "";
|
let out: ToolResult = "";
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const stepTool = getTool(steps[i].id);
|
const stepTool = getTool(steps[i].id);
|
||||||
@@ -331,11 +367,14 @@
|
|||||||
if (out && typeof out === "object" && "data" in out) {
|
if (out && typeof out === "object" && "data" in out) {
|
||||||
current = out as PixelImage;
|
current = out as PixelImage;
|
||||||
dims[i] = { width: current.width, height: current.height };
|
dims[i] = { width: current.width, height: current.height };
|
||||||
|
results[i] = current;
|
||||||
} else {
|
} else {
|
||||||
current = undefined;
|
current = undefined;
|
||||||
|
results[i] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stepDims = dims;
|
stepDims = dims;
|
||||||
|
stepResults = results;
|
||||||
assignResult(out);
|
assignResult(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError = toDisplayError(e);
|
displayError = toDisplayError(e);
|
||||||
@@ -443,24 +482,31 @@
|
|||||||
<div class="schema-tool">
|
<div class="schema-tool">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
<h1>{pageTitle(page)}</h1>
|
<h1>
|
||||||
|
{pageTitle(page)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-fav"
|
||||||
|
class:active={isFav(page.slug)}
|
||||||
|
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
|
||||||
|
aria-pressed={isFav(page.slug)}
|
||||||
|
onclick={() => toggleFav(page.slug)}
|
||||||
|
>
|
||||||
|
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
</h1>
|
||||||
<p class="lede">{pageDescription(page)}</p>
|
<p class="lede">{pageDescription(page)}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="workspace">
|
{#snippet stepCard(step: ChainStep, i: number)}
|
||||||
<section class="settings">
|
|
||||||
{#if canExtend}
|
|
||||||
<AddStepButton onadd={(id) => addStepAt(0, id)} />
|
|
||||||
{/if}
|
|
||||||
{#each steps as step, i (step.key)}
|
|
||||||
<StepCard
|
<StepCard
|
||||||
index={i + 1}
|
index={i + 1}
|
||||||
title={stepTitle(step.id)}
|
title={stepTitle(step.id)}
|
||||||
draggable
|
draggable
|
||||||
onremove={steps.length > 1
|
collapsed={step.collapsed ?? false}
|
||||||
? () => removeStepAt(step.key)
|
ontoggle={() => toggleStep(i)}
|
||||||
: undefined}
|
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
|
||||||
ondragstart={(e) => {
|
ondragstart={(e) => {
|
||||||
dragFrom = i;
|
dragFrom = i;
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer) {
|
||||||
@@ -475,6 +521,13 @@
|
|||||||
ondrop={(e) => onStepDrop(e, i)}
|
ondrop={(e) => onStepDrop(e, i)}
|
||||||
ondragend={() => (dragFrom = null)}
|
ondragend={() => (dragFrom = null)}
|
||||||
>
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
<ToolPickerButton
|
||||||
|
label={t("chain.changeTool")}
|
||||||
|
iconOnly
|
||||||
|
onadd={(id) => replaceStepTool(i, id)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
<SchemaFields
|
<SchemaFields
|
||||||
schema={toolSchemaOf(step)}
|
schema={toolSchemaOf(step)}
|
||||||
values={step.params}
|
values={step.params}
|
||||||
@@ -484,6 +537,16 @@
|
|||||||
onreset={() => resetStep(i)}
|
onreset={() => resetStep(i)}
|
||||||
/>
|
/>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !alignedMode}
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="settings">
|
||||||
|
{#if canExtend}
|
||||||
|
<AddStepButton onadd={(id) => addStepAt(0, id)} />
|
||||||
|
{/if}
|
||||||
|
{#each steps as step, i (step.key)}
|
||||||
|
{@render stepCard(step, i)}
|
||||||
{#if canExtend}
|
{#if canExtend}
|
||||||
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -507,6 +570,9 @@
|
|||||||
{format}
|
{format}
|
||||||
quality={currentQuality}
|
quality={currentQuality}
|
||||||
{alphaLoss}
|
{alphaLoss}
|
||||||
|
{stepResults}
|
||||||
|
{aligned}
|
||||||
|
ontogglealign={() => (aligned = !aligned)}
|
||||||
onformat={(v) => (format = v)}
|
onformat={(v) => (format = v)}
|
||||||
onquality={setFormatQuality}
|
onquality={setFormatQuality}
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
@@ -520,6 +586,117 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="aligned">
|
||||||
|
<div class="aligned-row">
|
||||||
|
<div></div>
|
||||||
|
<div class="panel-segment head-segment">
|
||||||
|
<span class="label">{t("resultCard.previewPanel")}</span>
|
||||||
|
<label class="align-toggle">
|
||||||
|
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
|
||||||
|
<span>{t("chain.alignToggle")}</span>
|
||||||
|
</label>
|
||||||
|
<SchemaActions
|
||||||
|
{inputMode}
|
||||||
|
{resultKind}
|
||||||
|
canDownload={result !== null}
|
||||||
|
{running}
|
||||||
|
{format}
|
||||||
|
quality={currentQuality}
|
||||||
|
{alphaLoss}
|
||||||
|
onupload={handleFile}
|
||||||
|
ondownload={download}
|
||||||
|
onformat={(v) => (format = v)}
|
||||||
|
onquality={setFormatQuality}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if errorText}
|
||||||
|
<p class="error" role="alert">{errorText}</p>
|
||||||
|
{/if}
|
||||||
|
{#if canExtend}
|
||||||
|
<div class="aligned-row">
|
||||||
|
<AddStepButton onadd={(id) => addStepAt(0, id)} />
|
||||||
|
<div class="panel-segment connect-segment"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each steps as step, i (step.key)}
|
||||||
|
{@const input = i === 0 ? null : stepResults[i - 1]}
|
||||||
|
{@const out = stepResults[i]}
|
||||||
|
{#if i > 0}
|
||||||
|
<div class="group-divider">
|
||||||
|
<div class="gd-cell"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="aligned-row">
|
||||||
|
{@render stepCard(step, i)}
|
||||||
|
<div
|
||||||
|
class="panel-segment pair-segment"
|
||||||
|
class:last-segment={i === steps.length - 1 && !canExtend}
|
||||||
|
>
|
||||||
|
{#if i === 0}
|
||||||
|
<SchemaSourceTile
|
||||||
|
mode={inputMode}
|
||||||
|
{source}
|
||||||
|
{textSource}
|
||||||
|
{running}
|
||||||
|
ontextinput={(v) => (textSource = v)}
|
||||||
|
onrendertext={run}
|
||||||
|
onupload={handleFile}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PreviewTile
|
||||||
|
label={t("chain.inputLegend")}
|
||||||
|
viewMode="image"
|
||||||
|
dims={input ? `${input.width} × ${input.height}` : undefined}
|
||||||
|
>
|
||||||
|
{#if input}
|
||||||
|
<img src={toDataUrl(input)} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="empty">{t("resultCard.noResult")}</span>
|
||||||
|
{/if}
|
||||||
|
</PreviewTile>
|
||||||
|
{/if}
|
||||||
|
{#if i === steps.length - 1}
|
||||||
|
<SchemaResultTile
|
||||||
|
{resultKind}
|
||||||
|
result={out}
|
||||||
|
{resultNote}
|
||||||
|
{fileResult}
|
||||||
|
{textResult}
|
||||||
|
textVars={verdictVars}
|
||||||
|
toolId={lastTool.id}
|
||||||
|
{running}
|
||||||
|
oncopy={copyText}
|
||||||
|
ondownloadtxt={downloadText}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PreviewTile
|
||||||
|
label={t("chain.stepResult", { n: i + 1 })}
|
||||||
|
viewMode="image"
|
||||||
|
dims={out ? `${out.width} × ${out.height}` : undefined}
|
||||||
|
>
|
||||||
|
{#if out}
|
||||||
|
<img src={toDataUrl(out)} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="empty">{t("resultCard.noResult")}</span>
|
||||||
|
{/if}
|
||||||
|
</PreviewTile>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if canExtend}
|
||||||
|
<div class="aligned-row">
|
||||||
|
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
||||||
|
<div
|
||||||
|
class="panel-segment connect-segment"
|
||||||
|
class:add-last={i === steps.length - 1}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="no-schema">{t("paramsCard.noSchema")}</p>
|
<p class="no-schema">{t("paramsCard.noSchema")}</p>
|
||||||
@@ -538,11 +715,27 @@
|
|||||||
margin-bottom: var(--space-xxxl);
|
margin-bottom: var(--space-xxxl);
|
||||||
}
|
}
|
||||||
.title-block h1 {
|
.title-block h1 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
margin: var(--space-s) 0 0;
|
margin: var(--space-s) 0 0;
|
||||||
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
|
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
.tool-fav {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-s);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tool-fav:hover,
|
||||||
|
.tool-fav.active {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
.lede {
|
.lede {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: var(--space-m) 0 0;
|
margin: var(--space-m) 0 0;
|
||||||
@@ -568,9 +761,100 @@
|
|||||||
.no-schema {
|
.no-schema {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
|
.aligned {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.aligned-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
/* Сегменты справа образуют «одну панель»: общий фон, боковые границы,
|
||||||
|
верх у шапки и низ у последней строки. */
|
||||||
|
.panel-segment {
|
||||||
|
background: var(--color-panel);
|
||||||
|
border-left: var(--size-border) solid var(--color-border);
|
||||||
|
border-right: var(--size-border) solid var(--color-border);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
.head-segment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-top: var(--size-border) solid var(--color-border);
|
||||||
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m) var(--radius-m) 0 0;
|
||||||
|
}
|
||||||
|
.head-segment .label {
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
letter-spacing: var(--space-text-l);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-main);
|
||||||
|
}
|
||||||
|
.pair-segment {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-l);
|
||||||
|
}
|
||||||
|
/* Коннектор в строках «Add step»: продолжает боковые границы панели. */
|
||||||
|
.connect-segment {
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.connect-segment.add-last {
|
||||||
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: 0 0 var(--radius-m) var(--radius-m);
|
||||||
|
}
|
||||||
|
/* Полоса-разделитель между группами: ячейки с фоном и боковыми границами
|
||||||
|
(как у карточки слева и панели справа), сверху — линия через всю ширину. */
|
||||||
|
.group-divider {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
}
|
||||||
|
.gd-cell {
|
||||||
|
grid-column: 2;
|
||||||
|
background: var(--color-panel);
|
||||||
|
border-left: var(--size-border) solid var(--color-border);
|
||||||
|
border-right: var(--size-border) solid var(--color-border);
|
||||||
|
padding: var(--space-s) 0;
|
||||||
|
}
|
||||||
|
.group-divider::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
border-top: var(--size-border) solid var(--color-border);
|
||||||
|
}
|
||||||
|
.last-segment {
|
||||||
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: 0 0 var(--radius-m) var(--radius-m);
|
||||||
|
}
|
||||||
|
.align-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
@media (--bp-tablet) {
|
@media (--bp-tablet) {
|
||||||
.workspace {
|
.workspace {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.aligned-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
|
import { chainablePages } from "$lib/pipeline.svelte";
|
||||||
|
import type { Page } from "$lib/registry";
|
||||||
|
import { Plus, Repeat } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
onadd: (toolId: string) => void;
|
||||||
|
}
|
||||||
|
let { label, iconOnly = false, onadd }: Props = $props();
|
||||||
|
|
||||||
|
const pages = chainablePages();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function choose(page: Page) {
|
||||||
|
onadd(page.steps[0].id);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!root?.contains(e.target as Node)) open = false;
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") open = false;
|
||||||
|
}
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tool-picker" bind:this={root}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={iconOnly ? "icon-btn" : "add-btn"}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={iconOnly ? label : undefined}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
>
|
||||||
|
{#if iconOnly}
|
||||||
|
<Icon icon={Repeat} size={14} />
|
||||||
|
{:else}
|
||||||
|
<Icon icon={Plus} size={14} />
|
||||||
|
<span>{label}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
{#each pages as page (page.slug)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => choose(page)}
|
||||||
|
>
|
||||||
|
{pageTitle(page)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tool-picker {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.add-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-main);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-main);
|
||||||
|
color: var(--color-background);
|
||||||
|
font: 600 var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-s);
|
||||||
|
border: var(--size-border) solid transparent;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-s));
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
min-width: calc(var(--space-xxxl) * 8);
|
||||||
|
max-height: calc(var(--space-xxxl) * 8);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-panel);
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid transparent;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.item:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
let { path, size = 16 }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={path} />
|
||||||
|
</svg>
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resize,
|
resize,
|
||||||
rotate90,
|
rotate90,
|
||||||
splitToParts,
|
splitToParts,
|
||||||
|
splitToTileSize,
|
||||||
tile,
|
tile,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
import { expectImageEqual, makeImage } from "./test-helpers";
|
import { expectImageEqual, makeImage } from "./test-helpers";
|
||||||
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
|
|||||||
expect(parts[0].height).toBe(6);
|
expect(parts[0].height).toBe(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("splitToTileSize", () => {
|
||||||
|
function grid(w: number, h: number): PixelImage {
|
||||||
|
const pixels: number[][] = [];
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const v = y * w + x + 1;
|
||||||
|
pixels.push([v, v, v, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return makeImage(w, h, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("кратный размер: части ровно tileW×tileH без паддинга", () => {
|
||||||
|
const parts = splitToTileSize(grid(4, 4), 2, 2);
|
||||||
|
expect(parts).toHaveLength(4);
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(part.width).toBe(2);
|
||||||
|
expect(part.height).toBe(2);
|
||||||
|
}
|
||||||
|
expectImageEqual(parts[1], [
|
||||||
|
[3, 3, 3, 255],
|
||||||
|
[4, 4, 4, 255],
|
||||||
|
[7, 7, 7, 255],
|
||||||
|
[8, 8, 8, 255],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("некратный размер: канвас дополняется прозрачным до кратности", () => {
|
||||||
|
const parts = splitToTileSize(grid(5, 3), 3, 2);
|
||||||
|
expect(parts).toHaveLength(4);
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(part.width).toBe(3);
|
||||||
|
expect(part.height).toBe(2);
|
||||||
|
}
|
||||||
|
expectImageEqual(parts[1], [
|
||||||
|
[4, 4, 4, 255],
|
||||||
|
[5, 5, 5, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[9, 9, 9, 255],
|
||||||
|
[10, 10, 10, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]);
|
||||||
|
expectImageEqual(parts[2], [
|
||||||
|
[11, 11, 11, 255],
|
||||||
|
[12, 12, 12, 255],
|
||||||
|
[13, 13, 13, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("тайл больше картинки — одна часть с паддингом", () => {
|
||||||
|
const parts = splitToTileSize(grid(2, 2), 4, 4);
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0].width).toBe(4);
|
||||||
|
expect(parts[0].height).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ export function splitToParts(
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
|
||||||
|
export function splitToTileSize(
|
||||||
|
img: PixelImage,
|
||||||
|
tileW: number,
|
||||||
|
tileH: number,
|
||||||
|
): PixelImage[] {
|
||||||
|
const tw = Math.max(1, Math.trunc(tileW));
|
||||||
|
const th = Math.max(1, Math.trunc(tileH));
|
||||||
|
const cols = Math.max(1, Math.ceil(img.width / tw));
|
||||||
|
const rows = Math.max(1, Math.ceil(img.height / th));
|
||||||
|
const gridW = cols * tw;
|
||||||
|
const gridH = rows * th;
|
||||||
|
const padded =
|
||||||
|
gridW === img.width && gridH === img.height
|
||||||
|
? img
|
||||||
|
: expandCanvas(img, 0, 0, gridW - img.width, gridH - img.height);
|
||||||
|
const parts: PixelImage[] = [];
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
parts.push(crop(padded, col * tw, row * th, tw, th));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
export function centerByAlpha(img: PixelImage): PixelImage {
|
export function centerByAlpha(img: PixelImage): PixelImage {
|
||||||
let minX = img.width;
|
let minX = img.width;
|
||||||
let minY = img.height;
|
let minY = img.height;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
|
const KEY = "fav-tools";
|
||||||
|
|
||||||
|
const slugs = new SvelteSet<string>();
|
||||||
|
|
||||||
|
function storage(): Storage | null {
|
||||||
|
return typeof localStorage === "undefined" ? null : localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initFavorites(): void {
|
||||||
|
const raw = storage()?.getItem(KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const list: unknown = JSON.parse(raw);
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const s of list) if (typeof s === "string") slugs.add(s);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// мусор в localStorage — работаем с пустым набором
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFav(slug: string): boolean {
|
||||||
|
return slugs.has(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFav(slug: string): void {
|
||||||
|
if (slugs.has(slug)) {
|
||||||
|
slugs.delete(slug);
|
||||||
|
} else {
|
||||||
|
slugs.add(slug);
|
||||||
|
}
|
||||||
|
storage()?.setItem(KEY, JSON.stringify([...slugs]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Избранные страницы из списка (порядок сохраняется). */
|
||||||
|
export function favPages<T extends { slug: string }>(pages: readonly T[]): T[] {
|
||||||
|
return pages.filter((p) => slugs.has(p.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Те же страницы, но избранные первыми (бонус в выдаче поиска). */
|
||||||
|
export function favFirst<T extends { slug: string }>(pages: readonly T[]): T[] {
|
||||||
|
return [...pages].sort(
|
||||||
|
(a, b) => Number(slugs.has(b.slug)) - Number(slugs.has(a.slug)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
favFirst,
|
||||||
|
favPages,
|
||||||
|
initFavorites,
|
||||||
|
isFav,
|
||||||
|
toggleFav,
|
||||||
|
} from "./favorites.svelte";
|
||||||
|
|
||||||
|
describe("favorites", () => {
|
||||||
|
it("toggle добавляет и убирает, isFav отражает состояние", () => {
|
||||||
|
expect(isFav("flip-png")).toBe(false);
|
||||||
|
toggleFav("flip-png");
|
||||||
|
expect(isFav("flip-png")).toBe(true);
|
||||||
|
toggleFav("flip-png");
|
||||||
|
expect(isFav("flip-png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favPages фильтрует, favFirst поднимает избранное", () => {
|
||||||
|
toggleFav("resize-png");
|
||||||
|
const pages = [{ slug: "crop-png" }, { slug: "resize-png" }];
|
||||||
|
expect(favPages(pages)).toEqual([{ slug: "resize-png" }]);
|
||||||
|
expect(favFirst(pages).map((p) => p.slug)).toEqual([
|
||||||
|
"resize-png",
|
||||||
|
"crop-png",
|
||||||
|
]);
|
||||||
|
toggleFav("resize-png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initFavorites без localStorage и с мусором не падает", () => {
|
||||||
|
expect(() => initFavorites()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ export const en: Dict = {
|
|||||||
lead: "Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.",
|
lead: "Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.",
|
||||||
toolsAvailable: "Tools available",
|
toolsAvailable: "Tools available",
|
||||||
toolsCount: "Tools",
|
toolsCount: "Tools",
|
||||||
|
favorites: "Favorites",
|
||||||
},
|
},
|
||||||
toolPage: {
|
toolPage: {
|
||||||
fallbackTitle: "Tool",
|
fallbackTitle: "Tool",
|
||||||
@@ -49,6 +50,11 @@ export const en: Dict = {
|
|||||||
},
|
},
|
||||||
chain: {
|
chain: {
|
||||||
addStep: "Add step",
|
addStep: "Add step",
|
||||||
|
changeTool: "Change tool",
|
||||||
|
collapseStep: "Collapse step",
|
||||||
|
expandStep: "Expand step",
|
||||||
|
stepResult: "Result {n}",
|
||||||
|
alignToggle: "Align tools and preview",
|
||||||
inputLegend: "Input",
|
inputLegend: "Input",
|
||||||
resultLegend: "Result",
|
resultLegend: "Result",
|
||||||
paramsLegend: "Parameters",
|
paramsLegend: "Parameters",
|
||||||
@@ -162,6 +168,11 @@ export const en: Dict = {
|
|||||||
removeColor: "Remove color",
|
removeColor: "Remove color",
|
||||||
themeToggle: "Toggle theme",
|
themeToggle: "Toggle theme",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
|
addFav: "Add to favorites",
|
||||||
|
removeFav: "Remove from favorites",
|
||||||
|
github: "GitHub",
|
||||||
|
telegram: "Telegram",
|
||||||
|
twitch: "Twitch",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: "This tool does not process images",
|
noImageRun: "This tool does not process images",
|
||||||
@@ -230,6 +241,8 @@ export const en: Dict = {
|
|||||||
colorTolerance: "Color tolerance",
|
colorTolerance: "Color tolerance",
|
||||||
cols: "Cols",
|
cols: "Cols",
|
||||||
columns: "Columns",
|
columns: "Columns",
|
||||||
|
splitMode: "Split by",
|
||||||
|
tileSize: "Tile size",
|
||||||
component: "Component",
|
component: "Component",
|
||||||
contrast: "Contrast",
|
contrast: "Contrast",
|
||||||
count: "Count",
|
count: "Count",
|
||||||
@@ -318,6 +331,8 @@ export const en: Dict = {
|
|||||||
cropArea: "Crop area",
|
cropArea: "Crop area",
|
||||||
fill: "Fill",
|
fill: "Fill",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
|
splitMode: "Split by",
|
||||||
|
tileSize: "Tile size",
|
||||||
noise: "Noise",
|
noise: "Noise",
|
||||||
offset: "Offset",
|
offset: "Offset",
|
||||||
options: "Options",
|
options: "Options",
|
||||||
@@ -370,6 +385,11 @@ export const en: Dict = {
|
|||||||
orientationSquare: "Square — the sides are equal.",
|
orientationSquare: "Square — the sides are equal.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"split-into-parts": {
|
||||||
|
options: {
|
||||||
|
mode: { grid: "Grid", tile: "Tile size" },
|
||||||
|
},
|
||||||
|
},
|
||||||
"show-transparent": {
|
"show-transparent": {
|
||||||
options: {
|
options: {
|
||||||
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const ru: Dict = {
|
|||||||
lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.",
|
lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.",
|
||||||
toolsAvailable: "Инструментов доступно",
|
toolsAvailable: "Инструментов доступно",
|
||||||
toolsCount: "Инструментов",
|
toolsCount: "Инструментов",
|
||||||
|
favorites: "Избранное",
|
||||||
},
|
},
|
||||||
toolPage: {
|
toolPage: {
|
||||||
fallbackTitle: "Инструмент",
|
fallbackTitle: "Инструмент",
|
||||||
@@ -50,6 +51,11 @@ export const ru: Dict = {
|
|||||||
},
|
},
|
||||||
chain: {
|
chain: {
|
||||||
addStep: "Добавить шаг",
|
addStep: "Добавить шаг",
|
||||||
|
changeTool: "Поменять инструмент",
|
||||||
|
collapseStep: "Свернуть шаг",
|
||||||
|
expandStep: "Развернуть шаг",
|
||||||
|
stepResult: "Результат {n}",
|
||||||
|
alignToggle: "Выровнять инструменты и просмотр",
|
||||||
inputLegend: "Вход",
|
inputLegend: "Вход",
|
||||||
resultLegend: "Результат",
|
resultLegend: "Результат",
|
||||||
paramsLegend: "Параметры",
|
paramsLegend: "Параметры",
|
||||||
@@ -163,6 +169,11 @@ export const ru: Dict = {
|
|||||||
removeColor: "Удалить цвет",
|
removeColor: "Удалить цвет",
|
||||||
themeToggle: "Переключить тему",
|
themeToggle: "Переключить тему",
|
||||||
language: "Язык",
|
language: "Язык",
|
||||||
|
addFav: "В избранное",
|
||||||
|
removeFav: "Убрать из избранного",
|
||||||
|
github: "GitHub",
|
||||||
|
telegram: "Telegram",
|
||||||
|
twitch: "Twitch",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: "Этот инструмент не обрабатывает изображения",
|
noImageRun: "Этот инструмент не обрабатывает изображения",
|
||||||
@@ -231,6 +242,8 @@ export const ru: Dict = {
|
|||||||
colorTolerance: "Допуск похожести",
|
colorTolerance: "Допуск похожести",
|
||||||
cols: "Колонки",
|
cols: "Колонки",
|
||||||
columns: "Столбцы",
|
columns: "Столбцы",
|
||||||
|
splitMode: "Делить по",
|
||||||
|
tileSize: "Размер тайла",
|
||||||
component: "Компонент",
|
component: "Компонент",
|
||||||
contrast: "Контраст",
|
contrast: "Контраст",
|
||||||
count: "Количество",
|
count: "Количество",
|
||||||
@@ -319,6 +332,8 @@ export const ru: Dict = {
|
|||||||
cropArea: "Область кадрирования",
|
cropArea: "Область кадрирования",
|
||||||
fill: "Заливка",
|
fill: "Заливка",
|
||||||
grid: "Сетка",
|
grid: "Сетка",
|
||||||
|
splitMode: "Режим разделения",
|
||||||
|
tileSize: "Размер тайла",
|
||||||
noise: "Шум",
|
noise: "Шум",
|
||||||
offset: "Смещение",
|
offset: "Смещение",
|
||||||
options: "Параметры",
|
options: "Параметры",
|
||||||
@@ -989,6 +1004,17 @@ export const ru: Dict = {
|
|||||||
color: "Цвет фона",
|
color: "Цвет фона",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"split-into-parts": {
|
||||||
|
params: {
|
||||||
|
mode: "Режим",
|
||||||
|
columns: "Столбцов",
|
||||||
|
rows: "Строк",
|
||||||
|
tile: "Размер тайла",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
mode: { grid: "Сетка", tile: "Размер тайла" },
|
||||||
|
},
|
||||||
|
},
|
||||||
tile: {
|
tile: {
|
||||||
params: { columns: "Столбцов", rows: "Строк" },
|
params: { columns: "Столбцов", rows: "Строк" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function selectOptions(
|
|||||||
const spec = (
|
const spec = (
|
||||||
field as { spec?: { kind?: string; options?: SelectOption[] } }
|
field as { spec?: { kind?: string; options?: SelectOption[] } }
|
||||||
).spec;
|
).spec;
|
||||||
if (spec?.kind !== "select") continue;
|
if (spec?.kind !== "select" && spec?.kind !== "segmented") continue;
|
||||||
for (const option of spec.options ?? []) result.push({ fieldId, option });
|
for (const option of spec.options ?? []) result.push({ fieldId, option });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { getTool, PAGES, type Page } from "./registry";
|
|||||||
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
|
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
|
||||||
|
|
||||||
export interface ChainStep {
|
export interface ChainStep {
|
||||||
/** Стабильный ключ для keyed each и drag-n-drop (переживает reorder). */
|
|
||||||
key: string;
|
key: string;
|
||||||
id: string;
|
id: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
|
collapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHAIN_VERSION = 1;
|
export const CHAIN_VERSION = 1;
|
||||||
@@ -77,6 +77,8 @@ export function parseChain(data: unknown): ChainStep[] | null {
|
|||||||
out.push({
|
out.push({
|
||||||
key: uid(),
|
key: uid(),
|
||||||
id,
|
id,
|
||||||
|
collapsed:
|
||||||
|
(raw as { collapsed?: unknown }).collapsed === true ? true : undefined,
|
||||||
params: sanitizeSchemaParams(
|
params: sanitizeSchemaParams(
|
||||||
tool.schema,
|
tool.schema,
|
||||||
(params ?? {}) as Record<string, unknown>,
|
(params ?? {}) as Record<string, unknown>,
|
||||||
@@ -101,7 +103,11 @@ export function saveChain(slug: string, steps: ChainStep[]): void {
|
|||||||
storageKey(slug),
|
storageKey(slug),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: CHAIN_VERSION,
|
version: CHAIN_VERSION,
|
||||||
steps: steps.map((s) => ({ id: s.id, params: s.params })),
|
steps: steps.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
params: s.params,
|
||||||
|
...(s.collapsed ? { collapsed: true } : {}),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,18 @@ describe("pipeline: parseChain (localStorage/импорт)", () => {
|
|||||||
expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
|
expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parseChain сохраняет collapsed, по умолчанию шаг развёрнут", () => {
|
||||||
|
const chain = parseChain({
|
||||||
|
version: CHAIN_VERSION,
|
||||||
|
steps: [
|
||||||
|
{ id: "crop", params: {}, collapsed: true },
|
||||||
|
{ id: "resize", params: {} },
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
expect(chain[0].collapsed).toBe(true);
|
||||||
|
expect(chain[1].collapsed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["нет версии", { steps: [{ id: "crop" }] }],
|
["нет версии", { steps: [{ id: "crop" }] }],
|
||||||
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
|
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
|
|||||||
*/
|
*/
|
||||||
export interface FieldSpecBase {
|
export interface FieldSpecBase {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
|
||||||
|
visibleWhen?: { field: string; equals: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberSpec extends FieldSpecBase {
|
export interface NumberSpec extends FieldSpecBase {
|
||||||
@@ -48,6 +50,15 @@ export interface SelectSpec<V extends string = string> extends FieldSpecBase {
|
|||||||
options: { value: V; label: string }[];
|
options: { value: V; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** То же, что select, но рендерится кнопками-сегментами (выбор в 1 клик). */
|
||||||
|
export interface SegmentedSpec<
|
||||||
|
V extends string = string,
|
||||||
|
> extends FieldSpecBase {
|
||||||
|
kind: "segmented";
|
||||||
|
default: V;
|
||||||
|
options: { value: V; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TextSpec extends FieldSpecBase {
|
export interface TextSpec extends FieldSpecBase {
|
||||||
kind: "text";
|
kind: "text";
|
||||||
default: string;
|
default: string;
|
||||||
@@ -212,6 +223,7 @@ export const fieldSpecs = {
|
|||||||
slider: {} as SliderSpec,
|
slider: {} as SliderSpec,
|
||||||
color: {} as ColorSpec,
|
color: {} as ColorSpec,
|
||||||
select: {} as SelectSpec,
|
select: {} as SelectSpec,
|
||||||
|
segmented: {} as SegmentedSpec,
|
||||||
text: {} as TextSpec,
|
text: {} as TextSpec,
|
||||||
checkbox: {} as CheckboxSpec,
|
checkbox: {} as CheckboxSpec,
|
||||||
dimension: {} as DimensionSpec,
|
dimension: {} as DimensionSpec,
|
||||||
@@ -275,6 +287,11 @@ export const field = {
|
|||||||
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
|
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
|
||||||
spec: { kind: "select", ...s },
|
spec: { kind: "select", ...s },
|
||||||
}),
|
}),
|
||||||
|
segmented: <V extends string>(
|
||||||
|
s: Omit<SegmentedSpec<V>, "kind">,
|
||||||
|
): Field<V> => ({
|
||||||
|
spec: { kind: "segmented", ...s },
|
||||||
|
}),
|
||||||
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
|
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
|
||||||
spec: { kind: "text", ...s },
|
spec: { kind: "text", ...s },
|
||||||
}),
|
}),
|
||||||
@@ -291,6 +308,7 @@ export const field = {
|
|||||||
maxFromSource?: boolean;
|
maxFromSource?: boolean;
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
|
visibleWhen?: { field: string; equals: string };
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
@@ -573,6 +591,7 @@ export function sanitizeSchemaParams<P>(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "select":
|
case "select":
|
||||||
|
case "segmented":
|
||||||
out[key] =
|
out[key] =
|
||||||
typeof raw === "string" && spec.options.some((o) => o.value === raw)
|
typeof raw === "string" && spec.options.some((o) => o.value === raw)
|
||||||
? raw
|
? raw
|
||||||
|
|||||||
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
|
|||||||
expect(files).toHaveLength(1024);
|
expect(files).toHaveLength(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("режим tile: сетка считается от размера тайла, части точные", async () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||||
|
const files = asFiles(
|
||||||
|
await tool.run({
|
||||||
|
source: solid(100, 70),
|
||||||
|
params: sanitizeSchemaParams(tool.schema, {
|
||||||
|
mode: "tile",
|
||||||
|
tile: { width: 32, height: 32 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).files;
|
||||||
|
// 100/32 → 4 колонки, 70/32 → 3 строки
|
||||||
|
expect(files).toHaveLength(12);
|
||||||
|
for (const file of files) {
|
||||||
|
expect(file.image.width).toBe(32);
|
||||||
|
expect(file.image.height).toBe(32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resize,
|
resize,
|
||||||
rotate90,
|
rotate90,
|
||||||
splitToParts,
|
splitToParts,
|
||||||
|
splitToTileSize,
|
||||||
symmetricCopy,
|
symmetricCopy,
|
||||||
tile,
|
tile,
|
||||||
trimToContent,
|
trimToContent,
|
||||||
@@ -437,17 +438,29 @@ const tileTool: Tool<TileParams> = {
|
|||||||
const MAX_SPLIT_PARTS = 1024;
|
const MAX_SPLIT_PARTS = 1024;
|
||||||
|
|
||||||
interface SplitPartsParams {
|
interface SplitPartsParams {
|
||||||
|
mode: "grid" | "tile";
|
||||||
columns: number;
|
columns: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
|
tile: Dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
export const splitPartsSchema = toolSchema<SplitPartsParams>(
|
||||||
|
{
|
||||||
|
mode: field.segmented({
|
||||||
|
label: "fields.splitMode",
|
||||||
|
default: "grid",
|
||||||
|
options: [
|
||||||
|
{ value: "grid", label: "Grid" },
|
||||||
|
{ value: "tile", label: "Tile size" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
columns: field.number({
|
columns: field.number({
|
||||||
label: "fields.columns",
|
label: "fields.columns",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 32,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
|
visibleWhen: { field: "mode", equals: "grid" },
|
||||||
}),
|
}),
|
||||||
rows: field.number({
|
rows: field.number({
|
||||||
label: "fields.rows",
|
label: "fields.rows",
|
||||||
@@ -455,8 +468,28 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
|||||||
max: 32,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
|
visibleWhen: { field: "mode", equals: "grid" },
|
||||||
}),
|
}),
|
||||||
});
|
tile: field.dimension({
|
||||||
|
label: "fields.tileSize",
|
||||||
|
min: 1,
|
||||||
|
max: 20000,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
presets: true,
|
||||||
|
visibleWhen: { field: "mode", equals: "tile" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "groups.splitMode", fields: ["mode"] },
|
||||||
|
{ title: "groups.grid", fields: ["columns", "rows"] },
|
||||||
|
{ title: "groups.tileSize", fields: ["tile"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const splitPartsTool: Tool<SplitPartsParams> = {
|
const splitPartsTool: Tool<SplitPartsParams> = {
|
||||||
id: "split-into-parts",
|
id: "split-into-parts",
|
||||||
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
run: (ctx) => {
|
run: (ctx) => {
|
||||||
const img = requireSource(ctx);
|
const img = requireSource(ctx);
|
||||||
const params = ctx.params as SplitPartsParams;
|
const params = ctx.params as SplitPartsParams;
|
||||||
const cols = Math.max(1, Math.trunc(params.columns));
|
const cols =
|
||||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
params.mode === "tile"
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(img.width / Math.max(1, Math.trunc(params.tile.width))),
|
||||||
|
)
|
||||||
|
: Math.max(1, Math.trunc(params.columns));
|
||||||
|
const rowsCount =
|
||||||
|
params.mode === "tile"
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(img.height / Math.max(1, Math.trunc(params.tile.height))),
|
||||||
|
)
|
||||||
|
: Math.max(1, Math.trunc(params.rows));
|
||||||
const count = cols * rowsCount;
|
const count = cols * rowsCount;
|
||||||
if (count > MAX_SPLIT_PARTS) {
|
if (count > MAX_SPLIT_PARTS) {
|
||||||
throw new ToolError("errors.tooManyParts", {
|
throw new ToolError("errors.tooManyParts", {
|
||||||
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
max: MAX_SPLIT_PARTS,
|
max: MAX_SPLIT_PARTS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const parts = splitToParts(img, cols, rowsCount);
|
const parts =
|
||||||
|
params.mode === "tile"
|
||||||
|
? splitToTileSize(img, params.tile.width, params.tile.height)
|
||||||
|
: splitToParts(img, cols, rowsCount);
|
||||||
const rowLen = String(rowsCount).length;
|
const rowLen = String(rowsCount).length;
|
||||||
const colLen = String(cols).length;
|
const colLen = String(cols).length;
|
||||||
const files: ToolImageFile[] = parts.map((image, index) => {
|
const files: ToolImageFile[] = parts.map((image, index) => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Публичный URL сайта для canonical/OG (абсолютные URL, включая base-path
|
||||||
|
* GitHub Pages). После покупки домена меняется здесь — `docs/plan-seo.md` §6.
|
||||||
|
* Внимание: `$app/paths.base` для этого не подходит — он относительный.
|
||||||
|
*/
|
||||||
|
export const SITE_URL = "https://ku6epxboctuk.github.io/easy-png-tools";
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Footer from "$lib/components/layout/Footer.svelte";
|
import Footer from "$lib/components/layout/Footer.svelte";
|
||||||
import TopBar from "$lib/components/layout/TopBar.svelte";
|
import TopBar from "$lib/components/layout/TopBar.svelte";
|
||||||
|
import { initFavorites } from "$lib/favorites.svelte";
|
||||||
import { initLocale } from "$lib/i18n/locale.svelte";
|
import { initLocale } from "$lib/i18n/locale.svelte";
|
||||||
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
initLocale();
|
initLocale();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
initFavorites();
|
||||||
});
|
});
|
||||||
|
|
||||||
let crumb = $derived(toCrumb(page.url.pathname));
|
let crumb = $derived(toCrumb(page.url.pathname));
|
||||||
|
|||||||
@@ -4,36 +4,59 @@
|
|||||||
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
||||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||||
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||||
import {
|
import {
|
||||||
pageDescription,
|
pageDescription,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
searchPages,
|
searchPages,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { PAGES } from "$lib/registry";
|
||||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let category = $state<string>("all");
|
let category = $state<string>("all");
|
||||||
|
|
||||||
|
const favorites = $derived(
|
||||||
|
searchPages(
|
||||||
|
PAGES.filter((p) => isFav(p.slug)),
|
||||||
|
query,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
PREVIEW_GROUPS.map((g) => ({
|
PREVIEW_GROUPS.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
label: t(`categories.${g.id}`),
|
label: t(`categories.${g.id}`),
|
||||||
pages: searchPages(g.pages, query).filter(
|
pages: favFirst(
|
||||||
|
searchPages(g.pages, query).filter(
|
||||||
() => category === "all" || category === g.id,
|
() => category === "all" || category === g.id,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
})).filter((g) => g.pages.length > 0),
|
})).filter((g) => g.pages.length > 0),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
|
||||||
<title>{t("home.defaultTitle")}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
<CatalogToolbar bind:query bind:category />
|
<CatalogToolbar bind:query bind:category />
|
||||||
<div class="catalog-groups">
|
<div class="catalog-groups">
|
||||||
|
{#if favorites.length > 0}
|
||||||
|
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
|
||||||
|
{#each favorites as page, i (page.slug)}
|
||||||
|
<ToolCard
|
||||||
|
title={pageTitle(page)}
|
||||||
|
slug={page.slug}
|
||||||
|
description={pageDescription(page)}
|
||||||
|
index={i + 1}
|
||||||
|
icon={TOOL_ICONS[page.slug]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</CatalogGroup>
|
||||||
|
{/if}
|
||||||
{#each groups as group (group.id)}
|
{#each groups as group (group.id)}
|
||||||
<CatalogGroup label={group.label} count={group.pages.length}>
|
<CatalogGroup label={group.label} count={group.pages.length}>
|
||||||
{#each group.pages as page, i (page.slug)}
|
{#each group.pages as page, i (page.slug)}
|
||||||
|
|||||||
@@ -4,37 +4,63 @@
|
|||||||
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
||||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||||
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||||
import {
|
import {
|
||||||
pageDescription,
|
pageDescription,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
searchPages,
|
searchPages,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { PAGES } from "$lib/registry";
|
||||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let category = $state<string>("all");
|
let category = $state<string>("all");
|
||||||
|
|
||||||
|
const favorites = $derived(
|
||||||
|
searchPages(
|
||||||
|
PAGES.filter((p) => isFav(p.slug)),
|
||||||
|
query,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
PREVIEW_GROUPS.map((g) => ({
|
PREVIEW_GROUPS.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
label: t(`categories.${g.id}`),
|
label: t(`categories.${g.id}`),
|
||||||
pages: searchPages(g.pages, query).filter(
|
pages: favFirst(
|
||||||
|
searchPages(g.pages, query).filter(
|
||||||
() => category === "all" || category === g.id,
|
() => category === "all" || category === g.id,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
})).filter((g) => g.pages.length > 0),
|
})).filter((g) => g.pages.length > 0),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo
|
||||||
<title>{t("catalog.pageTitle")}</title>
|
title={t("catalog.pageTitle")}
|
||||||
<meta name="description" content={t("catalog.metaDescription")} />
|
description={t("catalog.metaDescription")}
|
||||||
</svelte:head>
|
path="/list-tools"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
<CatalogToolbar bind:query bind:category />
|
<CatalogToolbar bind:query bind:category />
|
||||||
<div class="catalog-groups">
|
<div class="catalog-groups">
|
||||||
|
{#if favorites.length > 0}
|
||||||
|
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
|
||||||
|
{#each favorites as page, i (page.slug)}
|
||||||
|
<ToolCard
|
||||||
|
title={pageTitle(page)}
|
||||||
|
slug={page.slug}
|
||||||
|
description={pageDescription(page)}
|
||||||
|
index={i + 1}
|
||||||
|
icon={TOOL_ICONS[page.slug]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</CatalogGroup>
|
||||||
|
{/if}
|
||||||
{#each groups as group (group.id)}
|
{#each groups as group (group.id)}
|
||||||
<CatalogGroup label={group.label} count={group.pages.length}>
|
<CatalogGroup label={group.label} count={group.pages.length}>
|
||||||
{#each group.pages as page, i (page.slug)}
|
{#each group.pages as page, i (page.slug)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
||||||
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
|
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
|
||||||
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import { pageDescription, pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { getPageBySlug, getTool } from "$lib/registry";
|
import { getPageBySlug, getTool } from "$lib/registry";
|
||||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||||
@@ -14,8 +16,17 @@
|
|||||||
const tool = $derived(page ? getTool(page.steps[0].id) : undefined);
|
const tool = $derived(page ? getTool(page.steps[0].id) : undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if page}
|
||||||
|
<Seo
|
||||||
|
title={`easy-png-tools / ${pageTitle(page)}`}
|
||||||
|
description={pageDescription(page)}
|
||||||
|
path={`/tools/${page.slug}`}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>easy-png-tools / {page?.title ?? t("toolPage.fallbackTitle")}</title>
|
{#if !page}
|
||||||
|
<title>easy-png-tools / {t("toolPage.fallbackTitle")}</title>
|
||||||
|
{/if}
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user