mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
12
Commits
83e75b5589
...
fce1b20e85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce1b20e85 | ||
|
|
31ace359d7 | ||
|
|
fbe6139585 | ||
|
|
2f11cb86ad | ||
|
|
676be383d9 | ||
|
|
32cb369ca9 | ||
|
|
557c74b26e | ||
|
|
b7be973b54 | ||
|
|
47f42f5de3 | ||
|
|
1ba03b731d | ||
|
|
5c58451f2f | ||
|
|
e7d3925eb2 |
@@ -27,7 +27,7 @@ jobs:
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.XBOCT_BOT_ID }}
|
||||
app-id: ${{ vars.XBOCT_BOT_ID }}
|
||||
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
|
||||
|
||||
- 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.
|
||||
|
||||
- - -
|
||||
## [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
|
||||
#### Features
|
||||
- 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)
|
||||
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
|
||||
поиском, фильтром возможных инструментов и т.д.
|
||||
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
||||
режима aligned\normal
|
||||
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
||||
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
|
||||
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
|
||||
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
|
||||
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
|
||||
(text-source ↔ file).
|
||||
- [ ] **Идея 2. Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания
|
||||
звена до заголовка «Шаг n: название» (превью скрываются). Состояние
|
||||
свёрнутости помнить в workspace-pipeline.
|
||||
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||
Именованные цепочки в localStorage (список, создать/переименовать/
|
||||
удалить), быстрое переключение. Формат экспортного JSON расширить
|
||||
@@ -57,23 +57,14 @@
|
||||
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
|
||||
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
|
||||
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
|
||||
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
|
||||
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
|
||||
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
|
||||
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
|
||||
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
|
||||
исходник и результат построчно «параметры — исходник — результат»;
|
||||
промежуточные картинки при этом осознанно дублируются на экране —
|
||||
результат шага является исходником следующего.
|
||||
|
||||
## Инструменты
|
||||
|
||||
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
|
||||
такими делать вообще? оставить как seo? можно ли под капотом использовать
|
||||
другой инструмент с хардкодед настройками?
|
||||
- [ ] qr code to text, text to qr code
|
||||
- [ ] bar code to text, text to bar code
|
||||
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
||||
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
||||
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
||||
схема + core, из задачи C3)
|
||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||
выделенной области
|
||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||
@@ -144,11 +135,13 @@
|
||||
|
||||
## UI и качество
|
||||
|
||||
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
||||
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
||||
удобнее чем sectioned buttons
|
||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
||||
мгновенно пересортировываются и под мышкой оказывается другой элемент.
|
||||
Нужна или анимация, или механизм отложенного обновления позиций
|
||||
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
|
||||
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
|
||||
черная\белая\цветные\серая для проверки прозрачных картинок
|
||||
@@ -185,11 +178,6 @@
|
||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
||||
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
|
||||
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
|
||||
страницы инструмента; список slug в localStorage. Избранное показывается
|
||||
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
|
||||
popularity при скоринге).
|
||||
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
||||
`--color-main`/`--color-accent` подняты под текущий порог
|
||||
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
||||
@@ -239,7 +227,15 @@
|
||||
переименований уже опубликованных страниц; клонов с canonical не делать
|
||||
(`docs/plan-seo.md` §5)
|
||||
- [ ] 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)
|
||||
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
|
||||
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
|
||||
@@ -250,6 +246,9 @@
|
||||
|
||||
## Платформа (API/PWA)
|
||||
|
||||
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
|
||||
популярности инструментов через Vercel KV (serverless-функция: INCR при
|
||||
открытии инструмента, чтение топа для скоринга поиска).
|
||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||
(после wasm-фаз 5–7)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "easy-png-tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:rules": "vitest run eslint-plugins/__tests__",
|
||||
"test:e2e": "playwright test",
|
||||
"gen:og": "node scripts/gen-og.mjs",
|
||||
"format": "prettier --write . --log-level warn --cache",
|
||||
"format:check": "prettier --check . --log-level warn --cache",
|
||||
"lint": "eslint .",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.3.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:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
simple-icons:
|
||||
specifier: ^16.34.0
|
||||
version: 16.34.0
|
||||
devDependencies:
|
||||
'@csstools/postcss-global-data':
|
||||
specifier: ^4.0.0
|
||||
@@ -1387,6 +1390,10 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-icons@16.34.0:
|
||||
resolution: {integrity: sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
|
||||
sirv@3.0.2:
|
||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2805,6 +2812,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-icons@16.34.0: {}
|
||||
|
||||
sirv@3.0.2:
|
||||
dependencies:
|
||||
'@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">
|
||||
import { resolve } from "$app/paths";
|
||||
import { ArrowUpRight } from "@lucide/svelte";
|
||||
import { ArrowUpRight, Star } from "@lucide/svelte";
|
||||
import type { Component } from "svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||
import { t } from "$lib/i18n/t";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -20,6 +22,20 @@
|
||||
<strong>{title}</strong>
|
||||
{#if description}<span>{description}</span>{/if}
|
||||
</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">
|
||||
{index !== undefined ? index.toString().padStart(2, "0") : ""}
|
||||
</span>
|
||||
@@ -31,7 +47,7 @@
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
|
||||
var(--size-tool-arrow);
|
||||
var(--space-xxl) var(--size-tool-arrow);
|
||||
align-items: center;
|
||||
gap: var(--space-l);
|
||||
min-height: var(--size-tool-min-height);
|
||||
@@ -54,6 +70,19 @@
|
||||
color: var(--color-main);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { t } from "$lib/i18n/t";
|
||||
import type { Snippet } from "svelte";
|
||||
@@ -9,9 +9,11 @@
|
||||
title?: string;
|
||||
type?: string;
|
||||
draggable?: boolean;
|
||||
collapsed?: boolean;
|
||||
tools?: Snippet;
|
||||
children?: Snippet;
|
||||
onremove?: () => void;
|
||||
ontoggle?: () => void;
|
||||
ondragstart?: (e: DragEvent) => void;
|
||||
ondragover?: (e: DragEvent) => void;
|
||||
ondrop?: (e: DragEvent) => void;
|
||||
@@ -23,9 +25,11 @@
|
||||
title,
|
||||
type,
|
||||
draggable = false,
|
||||
collapsed = false,
|
||||
tools,
|
||||
children,
|
||||
onremove,
|
||||
ontoggle,
|
||||
ondragstart,
|
||||
ondragover,
|
||||
ondrop,
|
||||
@@ -54,6 +58,15 @@
|
||||
</div>
|
||||
<div class="step-tools">
|
||||
{#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}
|
||||
<IconButton
|
||||
icon={X}
|
||||
@@ -65,7 +78,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if children}
|
||||
{#if children && !collapsed}
|
||||
<div class="step-body">
|
||||
{@render children()}
|
||||
</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">
|
||||
import { resolve } from "$app/paths";
|
||||
import BrandIcon from "$lib/components/ui/BrandIcon.svelte";
|
||||
import LangToggle from "$lib/components/ui/LangToggle.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";
|
||||
|
||||
interface Props {
|
||||
@@ -23,6 +26,33 @@
|
||||
</a>
|
||||
<TopNav />
|
||||
<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} />
|
||||
<LangToggle />
|
||||
</div>
|
||||
@@ -64,4 +94,12 @@
|
||||
align-items: center;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
.social {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.social:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,123 +1,21 @@
|
||||
<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 { chainablePages } from "$lib/pipeline.svelte";
|
||||
import type { Page } from "$lib/registry";
|
||||
import { Plus } from "@lucide/svelte";
|
||||
import ToolPickerButton from "./ToolPickerButton.svelte";
|
||||
|
||||
interface Props {
|
||||
onadd: (toolId: string) => void;
|
||||
}
|
||||
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>
|
||||
|
||||
<div class="add-step" bind:this={root}>
|
||||
<button
|
||||
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 class="add-step">
|
||||
<ToolPickerButton label={t("chain.addStep")} {onadd} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.add-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
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>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
|
||||
import RangeControl from "$lib/components/fields/schema/RangeControl.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 OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
|
||||
import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
|
||||
@@ -42,6 +43,7 @@
|
||||
"color-pair": ColorPairControl,
|
||||
colors: ColorsControl,
|
||||
select: SelectControl,
|
||||
segmented: SegmentedControl,
|
||||
checkbox: CheckboxControl,
|
||||
text: TextControl,
|
||||
dimension: DimensionField,
|
||||
@@ -75,6 +77,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
||||
const isEmpty = $derived(Object.keys(schema.fields).length === 0);
|
||||
|
||||
function effectiveSpec(spec: FieldSpec): FieldSpec {
|
||||
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
|
||||
@@ -82,42 +85,54 @@
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
function isHidden(spec: FieldSpec): boolean {
|
||||
const rule = spec.visibleWhen;
|
||||
return rule !== undefined && values[rule.field] !== rule.equals;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each layoutGroups as group (group.key)}
|
||||
{#if group.title}
|
||||
<span class="group-title">{groupLabel(group.title)}</span>
|
||||
{/if}
|
||||
<div
|
||||
class="group-fields"
|
||||
style:grid-template-columns={group.cols > 1
|
||||
? `repeat(${group.cols}, minmax(0, 1fr))`
|
||||
: undefined}
|
||||
>
|
||||
{#each group.fields as id (id)}
|
||||
{@const Control = FIELDS[schema.fields[id].spec.kind]}
|
||||
<Control
|
||||
label={fieldLabel(schema.fields[id], id)}
|
||||
value={values[id]}
|
||||
spec={effectiveSpec(schema.fields[id].spec)}
|
||||
{toolId}
|
||||
fieldId={id}
|
||||
{sourceDims}
|
||||
onchange={(v, axis) => onchange(id, v, axis)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{#if isEmpty}
|
||||
<p class="no-params">{t("paramsCard.noParams")}</p>
|
||||
{:else}
|
||||
{#each layoutGroups as group (group.key)}
|
||||
{#if group.title}
|
||||
<span class="group-title">{groupLabel(group.title)}</span>
|
||||
{/if}
|
||||
<div
|
||||
class="group-fields"
|
||||
style:grid-template-columns={group.cols > 1
|
||||
? `repeat(${group.cols}, minmax(0, 1fr))`
|
||||
: undefined}
|
||||
>
|
||||
{#each group.fields as id (id)}
|
||||
{@const spec = schema.fields[id].spec}
|
||||
{@const Control = FIELDS[spec.kind]}
|
||||
{#if !isHidden(spec)}
|
||||
<Control
|
||||
label={fieldLabel(schema.fields[id], id)}
|
||||
value={values[id]}
|
||||
spec={effectiveSpec(spec)}
|
||||
{toolId}
|
||||
fieldId={id}
|
||||
{sourceDims}
|
||||
onchange={(v, axis) => onchange(id, v, axis)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="panel-foot">
|
||||
<Button
|
||||
icon={RotateCcw}
|
||||
label={t("ui.reset")}
|
||||
variant="clear"
|
||||
size="s"
|
||||
onclick={onreset}
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-foot">
|
||||
<Button
|
||||
icon={RotateCcw}
|
||||
label={t("ui.reset")}
|
||||
variant="clear"
|
||||
size="s"
|
||||
onclick={onreset}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.group-title {
|
||||
@@ -132,6 +147,11 @@
|
||||
.group-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.no-params {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.group-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import SchemaActions from "./SchemaActions.svelte";
|
||||
import SchemaResultTile from "./SchemaResultTile.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 { OutputMime } from "$lib/core/io";
|
||||
import { t } from "$lib/i18n/t";
|
||||
@@ -26,6 +29,9 @@
|
||||
textVars?: Record<string, string | number>;
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
stepResults?: (PixelImage | null)[];
|
||||
aligned?: boolean;
|
||||
ontogglealign?: () => void;
|
||||
format?: OutputMime;
|
||||
quality?: number;
|
||||
alphaLoss?: boolean;
|
||||
@@ -51,6 +57,9 @@
|
||||
textVars = undefined,
|
||||
running = false,
|
||||
error = "",
|
||||
stepResults = [],
|
||||
aligned = false,
|
||||
ontogglealign,
|
||||
format = "image/png",
|
||||
quality = undefined,
|
||||
alphaLoss = false,
|
||||
@@ -71,6 +80,16 @@
|
||||
|
||||
<div class="panel-head">
|
||||
<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
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
@@ -100,6 +119,17 @@
|
||||
{onrendertext}
|
||||
{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
|
||||
{resultKind}
|
||||
{result}
|
||||
@@ -118,7 +148,7 @@
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
padding-bottom: var(--space-l);
|
||||
border-bottom: var(--size-border) solid var(--color-border);
|
||||
@@ -130,6 +160,15 @@
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
margin: 0 0 var(--space-l);
|
||||
color: var(--color-danger);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 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 { debounce } from "$lib/core/debounce";
|
||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
||||
@@ -11,6 +11,7 @@
|
||||
downloadBlob,
|
||||
encode,
|
||||
outputFormatByMime,
|
||||
toDataUrl,
|
||||
type OutputMime,
|
||||
} from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
@@ -23,6 +24,7 @@
|
||||
import { t } from "$lib/i18n/t";
|
||||
import {
|
||||
createChain,
|
||||
createStep,
|
||||
insertStep,
|
||||
loadChain,
|
||||
moveStep,
|
||||
@@ -49,6 +51,14 @@
|
||||
import { downloadZip } from "$lib/zip";
|
||||
import { onMount } from "svelte";
|
||||
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 {
|
||||
page: Page;
|
||||
@@ -72,6 +82,8 @@
|
||||
// Последняя правленая ось dimension-поля — ведущая при lockAspect.
|
||||
const lastAxis: Record<string, "width" | "height"> = {};
|
||||
let stepDims = $state<(Dimension | undefined)[]>([]);
|
||||
let stepResults = $state<(PixelImage | null)[]>([]);
|
||||
let aligned = $state(false);
|
||||
let source = $state<PixelImage | null>(null);
|
||||
let result = $state<PixelImage | null>(null);
|
||||
let fileResult = $state<FileResult | null>(null);
|
||||
@@ -95,6 +107,9 @@
|
||||
const resultKind = $derived(lastTool.result ?? "image");
|
||||
// Цепочку можно продолжить, только если последний шаг отдаёт картинку.
|
||||
const canExtend = $derived((lastTool.result ?? "image") === "image");
|
||||
const alignedMode = $derived(
|
||||
aligned && resultKind === "image" && steps.length > 1,
|
||||
);
|
||||
|
||||
const alphaLoss = $derived(
|
||||
resultKind === "image" &&
|
||||
@@ -240,6 +255,26 @@
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
|
||||
@@ -305,6 +340,7 @@
|
||||
try {
|
||||
let current: PixelImage | undefined = source ?? undefined;
|
||||
const dims: (Dimension | undefined)[] = [];
|
||||
const results: (PixelImage | null)[] = [];
|
||||
let out: ToolResult = "";
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const stepTool = getTool(steps[i].id);
|
||||
@@ -331,11 +367,14 @@
|
||||
if (out && typeof out === "object" && "data" in out) {
|
||||
current = out as PixelImage;
|
||||
dims[i] = { width: current.width, height: current.height };
|
||||
results[i] = current;
|
||||
} else {
|
||||
current = undefined;
|
||||
results[i] = null;
|
||||
}
|
||||
}
|
||||
stepDims = dims;
|
||||
stepResults = results;
|
||||
assignResult(out);
|
||||
} catch (e) {
|
||||
displayError = toDisplayError(e);
|
||||
@@ -443,83 +482,221 @@
|
||||
<div class="schema-tool">
|
||||
<header class="header">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="settings">
|
||||
{#snippet stepCard(step: ChainStep, i: number)}
|
||||
<StepCard
|
||||
index={i + 1}
|
||||
title={stepTitle(step.id)}
|
||||
draggable
|
||||
collapsed={step.collapsed ?? false}
|
||||
ontoggle={() => toggleStep(i)}
|
||||
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
|
||||
ondragstart={(e) => {
|
||||
dragFrom = i;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(i));
|
||||
}
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
ondrop={(e) => onStepDrop(e, i)}
|
||||
ondragend={() => (dragFrom = null)}
|
||||
>
|
||||
{#snippet tools()}
|
||||
<ToolPickerButton
|
||||
label={t("chain.changeTool")}
|
||||
iconOnly
|
||||
onadd={(id) => replaceStepTool(i, id)}
|
||||
/>
|
||||
{/snippet}
|
||||
<SchemaFields
|
||||
schema={toolSchemaOf(step)}
|
||||
values={step.params}
|
||||
toolId={step.id}
|
||||
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
|
||||
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
||||
onreset={() => resetStep(i)}
|
||||
/>
|
||||
</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}
|
||||
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<SchemaPreview
|
||||
toolId={lastTool.id}
|
||||
{source}
|
||||
{result}
|
||||
{resultNote}
|
||||
{fileResult}
|
||||
{textSource}
|
||||
{textResult}
|
||||
textVars={verdictVars}
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
{running}
|
||||
error={errorText}
|
||||
{format}
|
||||
quality={currentQuality}
|
||||
{alphaLoss}
|
||||
{stepResults}
|
||||
{aligned}
|
||||
ontogglealign={() => (aligned = !aligned)}
|
||||
onformat={(v) => (format = v)}
|
||||
onquality={setFormatQuality}
|
||||
onupload={handleFile}
|
||||
ontextsource={(textValue) => {
|
||||
textSource = textValue;
|
||||
}}
|
||||
onrendertext={run}
|
||||
oncopytext={copyText}
|
||||
ondownloadtxt={downloadText}
|
||||
ondownload={download}
|
||||
/>
|
||||
</section>
|
||||
</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}
|
||||
<AddStepButton onadd={(id) => addStepAt(0, id)} />
|
||||
<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)}
|
||||
<StepCard
|
||||
index={i + 1}
|
||||
title={stepTitle(step.id)}
|
||||
draggable
|
||||
onremove={steps.length > 1
|
||||
? () => removeStepAt(step.key)
|
||||
: undefined}
|
||||
ondragstart={(e) => {
|
||||
dragFrom = i;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(i));
|
||||
}
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
ondrop={(e) => onStepDrop(e, i)}
|
||||
ondragend={() => (dragFrom = null)}
|
||||
>
|
||||
<SchemaFields
|
||||
schema={toolSchemaOf(step)}
|
||||
values={step.params}
|
||||
toolId={step.id}
|
||||
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
|
||||
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
||||
onreset={() => resetStep(i)}
|
||||
/>
|
||||
</StepCard>
|
||||
{@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}
|
||||
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
||||
<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}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<SchemaPreview
|
||||
toolId={lastTool.id}
|
||||
{source}
|
||||
{result}
|
||||
{resultNote}
|
||||
{fileResult}
|
||||
{textSource}
|
||||
{textResult}
|
||||
textVars={verdictVars}
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
{running}
|
||||
error={errorText}
|
||||
{format}
|
||||
quality={currentQuality}
|
||||
{alphaLoss}
|
||||
onformat={(v) => (format = v)}
|
||||
onquality={setFormatQuality}
|
||||
onupload={handleFile}
|
||||
ontextsource={(textValue) => {
|
||||
textSource = textValue;
|
||||
}}
|
||||
onrendertext={run}
|
||||
oncopytext={copyText}
|
||||
ondownloadtxt={downloadText}
|
||||
ondownload={download}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-schema">{t("paramsCard.noSchema")}</p>
|
||||
@@ -538,11 +715,27 @@
|
||||
margin-bottom: var(--space-xxxl);
|
||||
}
|
||||
.title-block h1 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
margin: var(--space-s) 0 0;
|
||||
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
|
||||
line-height: 1.1;
|
||||
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 {
|
||||
max-width: 100%;
|
||||
margin: var(--space-m) 0 0;
|
||||
@@ -568,9 +761,100 @@
|
||||
.no-schema {
|
||||
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) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.aligned-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</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,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
splitToTileSize,
|
||||
tile,
|
||||
} from "./geometry";
|
||||
import { expectImageEqual, makeImage } from "./test-helpers";
|
||||
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
|
||||
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 {
|
||||
let minX = img.width;
|
||||
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.",
|
||||
toolsAvailable: "Tools available",
|
||||
toolsCount: "Tools",
|
||||
favorites: "Favorites",
|
||||
},
|
||||
toolPage: {
|
||||
fallbackTitle: "Tool",
|
||||
@@ -49,6 +50,11 @@ export const en: Dict = {
|
||||
},
|
||||
chain: {
|
||||
addStep: "Add step",
|
||||
changeTool: "Change tool",
|
||||
collapseStep: "Collapse step",
|
||||
expandStep: "Expand step",
|
||||
stepResult: "Result {n}",
|
||||
alignToggle: "Align tools and preview",
|
||||
inputLegend: "Input",
|
||||
resultLegend: "Result",
|
||||
paramsLegend: "Parameters",
|
||||
@@ -162,6 +168,11 @@ export const en: Dict = {
|
||||
removeColor: "Remove color",
|
||||
themeToggle: "Toggle theme",
|
||||
language: "Language",
|
||||
addFav: "Add to favorites",
|
||||
removeFav: "Remove from favorites",
|
||||
github: "GitHub",
|
||||
telegram: "Telegram",
|
||||
twitch: "Twitch",
|
||||
},
|
||||
errors: {
|
||||
noImageRun: "This tool does not process images",
|
||||
@@ -230,6 +241,8 @@ export const en: Dict = {
|
||||
colorTolerance: "Color tolerance",
|
||||
cols: "Cols",
|
||||
columns: "Columns",
|
||||
splitMode: "Split by",
|
||||
tileSize: "Tile size",
|
||||
component: "Component",
|
||||
contrast: "Contrast",
|
||||
count: "Count",
|
||||
@@ -318,6 +331,8 @@ export const en: Dict = {
|
||||
cropArea: "Crop area",
|
||||
fill: "Fill",
|
||||
grid: "Grid",
|
||||
splitMode: "Split by",
|
||||
tileSize: "Tile size",
|
||||
noise: "Noise",
|
||||
offset: "Offset",
|
||||
options: "Options",
|
||||
@@ -370,6 +385,11 @@ export const en: Dict = {
|
||||
orientationSquare: "Square — the sides are equal.",
|
||||
},
|
||||
},
|
||||
"split-into-parts": {
|
||||
options: {
|
||||
mode: { grid: "Grid", tile: "Tile size" },
|
||||
},
|
||||
},
|
||||
"show-transparent": {
|
||||
options: {
|
||||
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ru: Dict = {
|
||||
lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.",
|
||||
toolsAvailable: "Инструментов доступно",
|
||||
toolsCount: "Инструментов",
|
||||
favorites: "Избранное",
|
||||
},
|
||||
toolPage: {
|
||||
fallbackTitle: "Инструмент",
|
||||
@@ -50,6 +51,11 @@ export const ru: Dict = {
|
||||
},
|
||||
chain: {
|
||||
addStep: "Добавить шаг",
|
||||
changeTool: "Поменять инструмент",
|
||||
collapseStep: "Свернуть шаг",
|
||||
expandStep: "Развернуть шаг",
|
||||
stepResult: "Результат {n}",
|
||||
alignToggle: "Выровнять инструменты и просмотр",
|
||||
inputLegend: "Вход",
|
||||
resultLegend: "Результат",
|
||||
paramsLegend: "Параметры",
|
||||
@@ -163,6 +169,11 @@ export const ru: Dict = {
|
||||
removeColor: "Удалить цвет",
|
||||
themeToggle: "Переключить тему",
|
||||
language: "Язык",
|
||||
addFav: "В избранное",
|
||||
removeFav: "Убрать из избранного",
|
||||
github: "GitHub",
|
||||
telegram: "Telegram",
|
||||
twitch: "Twitch",
|
||||
},
|
||||
errors: {
|
||||
noImageRun: "Этот инструмент не обрабатывает изображения",
|
||||
@@ -231,6 +242,8 @@ export const ru: Dict = {
|
||||
colorTolerance: "Допуск похожести",
|
||||
cols: "Колонки",
|
||||
columns: "Столбцы",
|
||||
splitMode: "Делить по",
|
||||
tileSize: "Размер тайла",
|
||||
component: "Компонент",
|
||||
contrast: "Контраст",
|
||||
count: "Количество",
|
||||
@@ -319,6 +332,8 @@ export const ru: Dict = {
|
||||
cropArea: "Область кадрирования",
|
||||
fill: "Заливка",
|
||||
grid: "Сетка",
|
||||
splitMode: "Режим разделения",
|
||||
tileSize: "Размер тайла",
|
||||
noise: "Шум",
|
||||
offset: "Смещение",
|
||||
options: "Параметры",
|
||||
@@ -989,6 +1004,17 @@ export const ru: Dict = {
|
||||
color: "Цвет фона",
|
||||
},
|
||||
},
|
||||
"split-into-parts": {
|
||||
params: {
|
||||
mode: "Режим",
|
||||
columns: "Столбцов",
|
||||
rows: "Строк",
|
||||
tile: "Размер тайла",
|
||||
},
|
||||
options: {
|
||||
mode: { grid: "Сетка", tile: "Размер тайла" },
|
||||
},
|
||||
},
|
||||
tile: {
|
||||
params: { columns: "Столбцов", rows: "Строк" },
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ function selectOptions(
|
||||
const spec = (
|
||||
field as { spec?: { kind?: string; options?: SelectOption[] } }
|
||||
).spec;
|
||||
if (spec?.kind !== "select") continue;
|
||||
if (spec?.kind !== "select" && spec?.kind !== "segmented") continue;
|
||||
for (const option of spec.options ?? []) result.push({ fieldId, option });
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { getTool, PAGES, type Page } from "./registry";
|
||||
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
|
||||
|
||||
export interface ChainStep {
|
||||
/** Стабильный ключ для keyed each и drag-n-drop (переживает reorder). */
|
||||
key: string;
|
||||
id: string;
|
||||
params: Record<string, unknown>;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export const CHAIN_VERSION = 1;
|
||||
@@ -77,6 +77,8 @@ export function parseChain(data: unknown): ChainStep[] | null {
|
||||
out.push({
|
||||
key: uid(),
|
||||
id,
|
||||
collapsed:
|
||||
(raw as { collapsed?: unknown }).collapsed === true ? true : undefined,
|
||||
params: sanitizeSchemaParams(
|
||||
tool.schema,
|
||||
(params ?? {}) as Record<string, unknown>,
|
||||
@@ -101,7 +103,11 @@ export function saveChain(slug: string, steps: ChainStep[]): void {
|
||||
storageKey(slug),
|
||||
JSON.stringify({
|
||||
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 });
|
||||
});
|
||||
|
||||
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([
|
||||
["нет версии", { steps: [{ id: "crop" }] }],
|
||||
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
|
||||
*/
|
||||
export interface FieldSpecBase {
|
||||
label?: string;
|
||||
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}
|
||||
|
||||
export interface NumberSpec extends FieldSpecBase {
|
||||
@@ -48,6 +50,15 @@ export interface SelectSpec<V extends string = string> extends FieldSpecBase {
|
||||
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 {
|
||||
kind: "text";
|
||||
default: string;
|
||||
@@ -212,6 +223,7 @@ export const fieldSpecs = {
|
||||
slider: {} as SliderSpec,
|
||||
color: {} as ColorSpec,
|
||||
select: {} as SelectSpec,
|
||||
segmented: {} as SegmentedSpec,
|
||||
text: {} as TextSpec,
|
||||
checkbox: {} as CheckboxSpec,
|
||||
dimension: {} as DimensionSpec,
|
||||
@@ -275,6 +287,11 @@ export const field = {
|
||||
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
|
||||
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> => ({
|
||||
spec: { kind: "text", ...s },
|
||||
}),
|
||||
@@ -291,6 +308,7 @@ export const field = {
|
||||
maxFromSource?: boolean;
|
||||
lockAspectWith?: string;
|
||||
presets?: boolean;
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}): Field<Dimension> => ({
|
||||
spec: { kind: "dimension", ...s },
|
||||
}),
|
||||
@@ -573,6 +591,7 @@ export function sanitizeSchemaParams<P>(
|
||||
break;
|
||||
}
|
||||
case "select":
|
||||
case "segmented":
|
||||
out[key] =
|
||||
typeof raw === "string" && spec.options.some((o) => o.value === raw)
|
||||
? raw
|
||||
|
||||
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
|
||||
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 при переполнении", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||
expect(() =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resize,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
splitToTileSize,
|
||||
symmetricCopy,
|
||||
tile,
|
||||
trimToContent,
|
||||
@@ -437,26 +438,58 @@ const tileTool: Tool<TileParams> = {
|
||||
const MAX_SPLIT_PARTS = 1024;
|
||||
|
||||
interface SplitPartsParams {
|
||||
mode: "grid" | "tile";
|
||||
columns: number;
|
||||
rows: number;
|
||||
tile: Dimension;
|
||||
}
|
||||
|
||||
export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
||||
columns: field.number({
|
||||
label: "fields.columns",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
}),
|
||||
rows: field.number({
|
||||
label: "fields.rows",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
}),
|
||||
});
|
||||
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({
|
||||
label: "fields.columns",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
visibleWhen: { field: "mode", equals: "grid" },
|
||||
}),
|
||||
rows: field.number({
|
||||
label: "fields.rows",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
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> = {
|
||||
id: "split-into-parts",
|
||||
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
||||
run: (ctx) => {
|
||||
const img = requireSource(ctx);
|
||||
const params = ctx.params as SplitPartsParams;
|
||||
const cols = Math.max(1, Math.trunc(params.columns));
|
||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
||||
const cols =
|
||||
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;
|
||||
if (count > MAX_SPLIT_PARTS) {
|
||||
throw new ToolError("errors.tooManyParts", {
|
||||
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
||||
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 colLen = String(cols).length;
|
||||
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 Footer from "$lib/components/layout/Footer.svelte";
|
||||
import TopBar from "$lib/components/layout/TopBar.svelte";
|
||||
import { initFavorites } from "$lib/favorites.svelte";
|
||||
import { initLocale } from "$lib/i18n/locale.svelte";
|
||||
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
@@ -27,6 +28,7 @@
|
||||
onMount(() => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
initFavorites();
|
||||
});
|
||||
|
||||
let crumb = $derived(toCrumb(page.url.pathname));
|
||||
|
||||
@@ -4,36 +4,59 @@
|
||||
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||
import Seo from "$lib/components/Seo.svelte";
|
||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||
import {
|
||||
pageDescription,
|
||||
pageTitle,
|
||||
searchPages,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { PAGES } from "$lib/registry";
|
||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||
|
||||
let query = $state("");
|
||||
let category = $state<string>("all");
|
||||
|
||||
const favorites = $derived(
|
||||
searchPages(
|
||||
PAGES.filter((p) => isFav(p.slug)),
|
||||
query,
|
||||
),
|
||||
);
|
||||
|
||||
const groups = $derived(
|
||||
PREVIEW_GROUPS.map((g) => ({
|
||||
id: g.id,
|
||||
label: t(`categories.${g.id}`),
|
||||
pages: searchPages(g.pages, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
pages: favFirst(
|
||||
searchPages(g.pages, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
),
|
||||
),
|
||||
})).filter((g) => g.pages.length > 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t("home.defaultTitle")}</title>
|
||||
</svelte:head>
|
||||
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
|
||||
|
||||
<div class="catalog-page">
|
||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||
<CatalogToolbar bind:query bind:category />
|
||||
<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)}
|
||||
<CatalogGroup label={group.label} count={group.pages.length}>
|
||||
{#each group.pages as page, i (page.slug)}
|
||||
|
||||
@@ -4,37 +4,63 @@
|
||||
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
|
||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||
import Seo from "$lib/components/Seo.svelte";
|
||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||
import {
|
||||
pageDescription,
|
||||
pageTitle,
|
||||
searchPages,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { PAGES } from "$lib/registry";
|
||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||
|
||||
let query = $state("");
|
||||
let category = $state<string>("all");
|
||||
|
||||
const favorites = $derived(
|
||||
searchPages(
|
||||
PAGES.filter((p) => isFav(p.slug)),
|
||||
query,
|
||||
),
|
||||
);
|
||||
|
||||
const groups = $derived(
|
||||
PREVIEW_GROUPS.map((g) => ({
|
||||
id: g.id,
|
||||
label: t(`categories.${g.id}`),
|
||||
pages: searchPages(g.pages, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
pages: favFirst(
|
||||
searchPages(g.pages, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
),
|
||||
),
|
||||
})).filter((g) => g.pages.length > 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t("catalog.pageTitle")}</title>
|
||||
<meta name="description" content={t("catalog.metaDescription")} />
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={t("catalog.pageTitle")}
|
||||
description={t("catalog.metaDescription")}
|
||||
path="/list-tools"
|
||||
/>
|
||||
|
||||
<div class="catalog-page">
|
||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||
<CatalogToolbar bind:query bind:category />
|
||||
<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)}
|
||||
<CatalogGroup label={group.label} count={group.pages.length}>
|
||||
{#each group.pages as page, i (page.slug)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import EmptyState from "$lib/components/feedback/EmptyState.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 { getPageBySlug, getTool } from "$lib/registry";
|
||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||
@@ -14,8 +16,17 @@
|
||||
const tool = $derived(page ? getTool(page.steps[0].id) : undefined);
|
||||
</script>
|
||||
|
||||
{#if page}
|
||||
<Seo
|
||||
title={`easy-png-tools / ${pageTitle(page)}`}
|
||||
description={pageDescription(page)}
|
||||
path={`/tools/${page.slug}`}
|
||||
/>
|
||||
{/if}
|
||||
<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" />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user