mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add chains page, update pipeline usage
This commit is contained in:
+11
-8
@@ -48,14 +48,17 @@ Coverage). Всё остальное в `core/**` — чистые функци
|
|||||||
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
|
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
|
||||||
скачала результат. Отсутствуют:
|
скачала результат. Отсутствуют:
|
||||||
|
|
||||||
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
|
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
|
||||||
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
|
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
|
||||||
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
|
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
|
||||||
полей двух инструментов — отдельная работа, описанная как фаза 11
|
`chains:named`). Страница инструмента всегда открывается чистой; при первом
|
||||||
`docs/roadmap.md`. Под неё зарезервированы
|
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
|
||||||
`components/display/StepCard.svelte` (сейчас импортируется только витриной
|
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
|
||||||
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
|
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
|
||||||
`errors.pipelineVersion`, `errors.noSteps`.
|
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
|
||||||
|
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
|
||||||
|
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
|
||||||
|
фазы 11 `docs/roadmap.md`.
|
||||||
- **Разделения страниц, которые делят один инструмент** — сводит все
|
- **Разделения страниц, которые делят один инструмент** — сводит все
|
||||||
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
|
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
|
||||||
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
|
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
|
||||||
|
|||||||
@@ -18,16 +18,6 @@
|
|||||||
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
|
||||||
режима aligned\normal
|
режима aligned\normal
|
||||||
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
- [ ] переделать визуал toggle компонента (которым режим переключается)
|
||||||
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
|
||||||
Именованные цепочки в localStorage (список, создать/переименовать/
|
|
||||||
удалить), быстрое переключение. Формат экспортного JSON расширить
|
|
||||||
опциональным полем имени; обратной совместимости со старыми выгрузками не
|
|
||||||
делаем — формат не устоялся: он появится вместе с фазой 11
|
|
||||||
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
|
|
||||||
со списком всех цепочек: удалить, переименовать, добавить новую,
|
|
||||||
«использовать» (переход на страницу инструмента с этой цепочкой). На
|
|
||||||
странице — упрощённый вид: только список инструментов цепочки, без
|
|
||||||
реального выполнения и параметров.
|
|
||||||
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
|
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
|
||||||
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
|
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
|
||||||
сохранённого chain по каждому файлу, сборка результата обратно в zip для
|
сохранённого chain по каждому файлу, сборка результата обратно в zip для
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { formatStamp } from "./core/datefmt";
|
||||||
|
import type { OutputMime } from "./core/io";
|
||||||
|
import type { PixelImage } from "./core/types";
|
||||||
|
import { t } from "./i18n/t";
|
||||||
|
import {
|
||||||
|
CHAIN_VERSION,
|
||||||
|
parseChain,
|
||||||
|
serializeSteps,
|
||||||
|
type ChainStep,
|
||||||
|
} from "./pipeline.svelte";
|
||||||
|
|
||||||
|
export interface NamedChain {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
updatedAt: number;
|
||||||
|
steps: ChainStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_KEY = "chains:named";
|
||||||
|
|
||||||
|
function storage(): Storage | null {
|
||||||
|
return typeof localStorage === "undefined" ? null : localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoChainName(date = new Date()): string {
|
||||||
|
return t("savedChains.autoName", {
|
||||||
|
stamp: formatStamp(date, "YYYY-MM-DD hh:mm"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lenient per entry: one broken chain must not kill the rest of the list.
|
||||||
|
* Steps reuse the same serialized shape and validation as parseChain.
|
||||||
|
*/
|
||||||
|
export function parseNamedChains(data: unknown): NamedChain[] {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
(data as { version?: unknown }).version !== CHAIN_VERSION ||
|
||||||
|
!Array.isArray((data as { chains?: unknown }).chains)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: NamedChain[] = [];
|
||||||
|
for (const raw of (data as { chains: unknown[] }).chains) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
const { id, name, updatedAt, steps } = raw as {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
updatedAt?: unknown;
|
||||||
|
steps?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof id !== "string" || typeof name !== "string") continue;
|
||||||
|
const parsed = parseChain({ version: CHAIN_VERSION, steps });
|
||||||
|
if (!parsed) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
updatedAt:
|
||||||
|
typeof updatedAt === "number" && Number.isFinite(updatedAt)
|
||||||
|
? updatedAt
|
||||||
|
: 0,
|
||||||
|
steps: parsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNamedChains(
|
||||||
|
chains: NamedChain[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: CHAIN_VERSION,
|
||||||
|
chains: chains.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
steps: serializeSteps(c.steps),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll(): NamedChain[] {
|
||||||
|
const raw = storage()?.getItem(STORE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return parseNamedChains(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(chains: NamedChain[]): void {
|
||||||
|
storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first. */
|
||||||
|
export function listChains(): NamedChain[] {
|
||||||
|
return readAll().sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChain(id: string): NamedChain | undefined {
|
||||||
|
return readAll().find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNamedChain(name: string, steps: ChainStep[]): NamedChain {
|
||||||
|
const chain: NamedChain = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name.trim() || autoChainName(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
writeAll([...readAll(), chain]);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChainSteps(id: string, steps: ChainStep[]): void {
|
||||||
|
writeAll(
|
||||||
|
readAll().map((c) =>
|
||||||
|
c.id === id ? { ...c, steps, updatedAt: Date.now() } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameChain(id: string, name: string): void {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
writeAll(readAll().map((c) => (c.id === id ? { ...c, name: trimmed } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteChain(id: string): void {
|
||||||
|
writeAll(readAll().filter((c) => c.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State that cannot persist across the tool -> pipeline route swap. */
|
||||||
|
export interface ChainHandoff {
|
||||||
|
source: PixelImage | null;
|
||||||
|
textSource: string;
|
||||||
|
format: OutputMime;
|
||||||
|
limitKb: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handoffs = new Map<string, ChainHandoff>();
|
||||||
|
|
||||||
|
export function stashHandoff(id: string, handoff: ChainHandoff): void {
|
||||||
|
handoffs.set(id, handoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeHandoff(id: string): ChainHandoff | undefined {
|
||||||
|
const handoff = handoffs.get(id);
|
||||||
|
handoffs.delete(id);
|
||||||
|
return handoff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
autoChainName,
|
||||||
|
parseNamedChains,
|
||||||
|
serializeNamedChains,
|
||||||
|
} from "./chains.svelte";
|
||||||
|
import {
|
||||||
|
CHAIN_VERSION,
|
||||||
|
createChain,
|
||||||
|
isStructuralDefault,
|
||||||
|
} from "./pipeline.svelte";
|
||||||
|
import { getPageBySlug } from "./registry";
|
||||||
|
|
||||||
|
const cropPage = getPageBySlug("crop-png")!;
|
||||||
|
|
||||||
|
describe("named chains: parse/serialize round-trip", () => {
|
||||||
|
const chain = {
|
||||||
|
id: "c1",
|
||||||
|
name: "My chain",
|
||||||
|
updatedAt: 123,
|
||||||
|
steps: createChain(cropPage),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("round-trips a valid chain", () => {
|
||||||
|
const data = serializeNamedChains([chain]);
|
||||||
|
const parsed = parseNamedChains(JSON.parse(JSON.stringify(data)));
|
||||||
|
expect(parsed).toHaveLength(1);
|
||||||
|
expect(parsed[0]).toMatchObject({ id: "c1", name: "My chain" });
|
||||||
|
expect(parsed[0].steps[0].id).toBe("crop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a broken entry, keeps the rest", () => {
|
||||||
|
const good = JSON.parse(JSON.stringify(serializeNamedChains([chain]))) as {
|
||||||
|
chains: unknown[];
|
||||||
|
};
|
||||||
|
const data = {
|
||||||
|
version: CHAIN_VERSION,
|
||||||
|
chains: [
|
||||||
|
good.chains[0],
|
||||||
|
{ id: "bad", name: "Broken", steps: [{ id: "nope" }] },
|
||||||
|
{ nope: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const parsed = parseNamedChains(data);
|
||||||
|
expect(parsed.map((c) => c.id)).toEqual(["c1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["junk", "just a string"],
|
||||||
|
["no version", { chains: [] }],
|
||||||
|
["other version", { version: 99, chains: [] }],
|
||||||
|
])("invalid store -> empty list (%s)", (_name, data) => {
|
||||||
|
expect(parseNamedChains(data)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isStructuralDefault", () => {
|
||||||
|
it("true for the untouched page chain", () => {
|
||||||
|
expect(isStructuralDefault(cropPage, createChain(cropPage))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("false when a step is added or the first tool is swapped", () => {
|
||||||
|
const grown = [...createChain(cropPage), ...createChain(cropPage)];
|
||||||
|
expect(isStructuralDefault(cropPage, grown)).toBe(false);
|
||||||
|
const swapped = createChain(cropPage);
|
||||||
|
swapped[0] = { ...swapped[0], id: "resize" };
|
||||||
|
expect(isStructuralDefault(cropPage, swapped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("autoChainName", () => {
|
||||||
|
it("embeds the formatted stamp", () => {
|
||||||
|
const name = autoChainName(new Date(2026, 9, 8, 14, 35));
|
||||||
|
expect(name).toContain("2026-10-08 14:35");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href={resolve("/")}>{t("header.home")}</a>
|
<a href={resolve("/")}>{t("header.home")}</a>
|
||||||
<a href={resolve("/list-tools")}>{t("header.catalog")}</a>
|
<a href={resolve("/list-tools")}>{t("header.catalog")}</a>
|
||||||
|
<a href={resolve("/chains")}>{t("header.chains")}</a>
|
||||||
<a href={resolve("/kit")}>{t("header.uiKit")}</a>
|
<a href={resolve("/kit")}>{t("header.uiKit")}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import StepCard from "$lib/components/display/StepCard.svelte";
|
import StepCard from "$lib/components/display/StepCard.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import { ButtonVariantDefine } from "$lib/components/ui/define";
|
||||||
import Toggle from "$lib/components/ui/Toggle.svelte";
|
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||||
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||||
import { Star } from "@lucide/svelte";
|
import { Star } from "@lucide/svelte";
|
||||||
@@ -20,22 +24,29 @@
|
|||||||
import {
|
import {
|
||||||
pageDescription,
|
pageDescription,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
chainStepTitle,
|
||||||
verdictText,
|
verdictText,
|
||||||
} 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 {
|
||||||
|
autoChainName,
|
||||||
|
createNamedChain,
|
||||||
|
getChain,
|
||||||
|
stashHandoff,
|
||||||
|
takeHandoff,
|
||||||
|
updateChainSteps,
|
||||||
|
} from "$lib/chains.svelte";
|
||||||
import {
|
import {
|
||||||
createChain,
|
createChain,
|
||||||
createStep,
|
createStep,
|
||||||
insertStep,
|
insertStep,
|
||||||
loadChain,
|
isStructuralDefault,
|
||||||
moveStep,
|
moveStep,
|
||||||
removeStep,
|
removeStep,
|
||||||
saveChain,
|
|
||||||
type ChainStep,
|
type ChainStep,
|
||||||
} from "$lib/pipeline.svelte";
|
} from "$lib/pipeline.svelte";
|
||||||
import {
|
import {
|
||||||
getTool,
|
getTool,
|
||||||
PAGES,
|
|
||||||
type FileResult,
|
type FileResult,
|
||||||
type Page,
|
type Page,
|
||||||
type Tool,
|
type Tool,
|
||||||
@@ -64,11 +75,13 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
page: Page;
|
page: Page;
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
|
/** Named-chain mode: the chain id and display name; page is virtual. */
|
||||||
|
chainId?: string;
|
||||||
}
|
}
|
||||||
type DisplayError =
|
type DisplayError =
|
||||||
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
||||||
| { kind: "plain"; text: string };
|
| { kind: "plain"; text: string };
|
||||||
let { page, tool }: Props = $props();
|
let { page, tool, chainId = undefined }: Props = $props();
|
||||||
|
|
||||||
const schema = $derived(
|
const schema = $derived(
|
||||||
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
|
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
|
||||||
@@ -168,17 +181,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stepTitle(toolId: string): string {
|
function stepTitle(toolId: string): string {
|
||||||
const owner = PAGES.find((p) => p.steps[0].id === toolId);
|
return chainStepTitle(toolId);
|
||||||
return owner ? pageTitle(owner) : toolId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (!schema || page.slug === initedFor) return;
|
if (!schema) return;
|
||||||
initedFor = page.slug;
|
const host = chainId ? `chain:${chainId}` : page.slug;
|
||||||
steps = loadChain(page.slug) ?? createChain(page);
|
if (host === initedFor) return;
|
||||||
|
initedFor = host;
|
||||||
|
if (chainId) {
|
||||||
|
const handoff = takeHandoff(chainId);
|
||||||
|
steps = getChain(chainId)?.steps ?? createChain(page);
|
||||||
|
source = handoff?.source ?? null;
|
||||||
|
textSource = handoff?.textSource ?? "";
|
||||||
|
if (handoff?.format) format = handoff.format;
|
||||||
|
else format = lastTool.output?.mime ?? "image/png";
|
||||||
|
limitKb = handoff?.limitKb;
|
||||||
|
} else {
|
||||||
|
steps = createChain(page);
|
||||||
format = lastTool.output?.mime ?? "image/png";
|
format = lastTool.output?.mime ?? "image/png";
|
||||||
limitKb = undefined;
|
limitKb = undefined;
|
||||||
source = null;
|
source = null;
|
||||||
|
textSource = "";
|
||||||
|
}
|
||||||
result = null;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
@@ -439,11 +464,28 @@
|
|||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chain persists per page slug; save only after initialization.
|
// Named-chain mode autosaves into the chains store; a plain tool page
|
||||||
|
// persists nothing -- it becomes a named chain on structural change below.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!initedFor) return;
|
if (!initedFor || !chainId) return;
|
||||||
void steps;
|
void steps;
|
||||||
saveChain(initedFor, steps);
|
updateChainSteps(chainId, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tool page turns into a pipeline on the first structural change (step
|
||||||
|
// added, first tool swapped): create the named chain and swap routes
|
||||||
|
// seamlessly -- steps persist via the store, the source via the handoff.
|
||||||
|
$effect(() => {
|
||||||
|
if (!initedFor || chainId) return;
|
||||||
|
void steps;
|
||||||
|
if (isStructuralDefault(page, steps)) return;
|
||||||
|
const chain = createNamedChain(autoChainName(), steps);
|
||||||
|
stashHandoff(chain.id, { source, textSource, format, limitKb });
|
||||||
|
goto(resolve(`/pipeline?id=${chain.id}`), {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -482,6 +524,18 @@
|
|||||||
if (e instanceof Error) return { kind: "plain", text: e.message };
|
if (e instanceof Error) return { kind: "plain", text: e.message };
|
||||||
return { kind: "plain", text: String(e) };
|
return { kind: "plain", text: String(e) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit save for a structurally default chain (single tuned step): the
|
||||||
|
// auto-create effect only fires on structural change.
|
||||||
|
function saveAsChain() {
|
||||||
|
const chain = createNamedChain(autoChainName(), steps);
|
||||||
|
stashHandoff(chain.id, { source, textSource, format, limitKb });
|
||||||
|
goto(resolve(`/pipeline?id=${chain.id}`), {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if schema}
|
{#if schema}
|
||||||
@@ -490,6 +544,7 @@
|
|||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
<h1>
|
<h1>
|
||||||
{pageTitle(page)}
|
{pageTitle(page)}
|
||||||
|
{#if !chainId}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tool-fav"
|
class="tool-fav"
|
||||||
@@ -498,11 +553,25 @@
|
|||||||
aria-pressed={isFav(page.slug)}
|
aria-pressed={isFav(page.slug)}
|
||||||
onclick={() => toggleFav(page.slug)}
|
onclick={() => toggleFav(page.slug)}
|
||||||
>
|
>
|
||||||
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} />
|
<Star
|
||||||
|
size={18}
|
||||||
|
fill={isFav(page.slug) ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
|
{#if pageDescription(page)}
|
||||||
<p class="lede">{pageDescription(page)}</p>
|
<p class="lede">{pageDescription(page)}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !chainId}
|
||||||
|
<Button
|
||||||
|
label={t("savedChains.saveAs")}
|
||||||
|
variant={ButtonVariantDefine.OUTLINE}
|
||||||
|
size="s"
|
||||||
|
onclick={saveAsChain}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#snippet stepCard(step: ChainStep, i: number)}
|
{#snippet stepCard(step: ChainStep, i: number)}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type HeaderStrings = {
|
|||||||
footerNote: string;
|
footerNote: string;
|
||||||
home: string;
|
home: string;
|
||||||
uiKit: string;
|
uiKit: string;
|
||||||
|
chains: string;
|
||||||
|
pipeline: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Dict = {
|
export type Dict = {
|
||||||
@@ -48,6 +50,7 @@ export type Dict = {
|
|||||||
catalog: Record<string, string>;
|
catalog: Record<string, string>;
|
||||||
toolPage: Record<string, string>;
|
toolPage: Record<string, string>;
|
||||||
chain: Record<string, string>;
|
chain: Record<string, string>;
|
||||||
|
savedChains: Record<string, string>;
|
||||||
sourceCard: Record<string, string>;
|
sourceCard: Record<string, string>;
|
||||||
resultCard: Record<string, string>;
|
resultCard: Record<string, string>;
|
||||||
paramsCard: Record<string, string>;
|
paramsCard: Record<string, string>;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const en: Dict = {
|
|||||||
sectionsAria: "Sections",
|
sectionsAria: "Sections",
|
||||||
home: "Home page",
|
home: "Home page",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
|
chains: "Pipelines",
|
||||||
|
pipeline: "Pipeline",
|
||||||
footerNote:
|
footerNote:
|
||||||
"All operations run locally in your browser — your files are never uploaded anywhere.",
|
"All operations run locally in your browser — your files are never uploaded anywhere.",
|
||||||
},
|
},
|
||||||
@@ -62,6 +64,23 @@ export const en: Dict = {
|
|||||||
busyHint: "Running chain step",
|
busyHint: "Running chain step",
|
||||||
removeStepAria: "Remove step",
|
removeStepAria: "Remove step",
|
||||||
},
|
},
|
||||||
|
savedChains: {
|
||||||
|
pageTitle: "Saved pipelines — easy-png-tools",
|
||||||
|
metaDescription:
|
||||||
|
"Your saved image pipelines: named tool chains stored locally in your browser.",
|
||||||
|
heading: "Saved pipelines",
|
||||||
|
empty:
|
||||||
|
"No saved pipelines yet — add a step on any tool page and it will appear here.",
|
||||||
|
autoName: "Pipeline {stamp}",
|
||||||
|
saveAs: "Save as pipeline",
|
||||||
|
newChain: "New pipeline",
|
||||||
|
use: "Open",
|
||||||
|
rename: "Rename",
|
||||||
|
renameSave: "Save name",
|
||||||
|
deleteAction: "Delete",
|
||||||
|
confirmDelete: "Sure?",
|
||||||
|
nameAria: "Pipeline name",
|
||||||
|
},
|
||||||
sourceCard: {
|
sourceCard: {
|
||||||
source: "Source",
|
source: "Source",
|
||||||
replaceImage: "Replace image",
|
replaceImage: "Replace image",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const ru: Dict = {
|
|||||||
sectionsAria: "Разделы",
|
sectionsAria: "Разделы",
|
||||||
home: "Главная",
|
home: "Главная",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
|
chains: "Пайплайны",
|
||||||
|
pipeline: "Пайплайн",
|
||||||
footerNote:
|
footerNote:
|
||||||
"Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.",
|
"Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.",
|
||||||
},
|
},
|
||||||
@@ -63,6 +65,23 @@ export const ru: Dict = {
|
|||||||
busyHint: "Выполняется шаг цепочки",
|
busyHint: "Выполняется шаг цепочки",
|
||||||
removeStepAria: "Убрать шаг",
|
removeStepAria: "Убрать шаг",
|
||||||
},
|
},
|
||||||
|
savedChains: {
|
||||||
|
pageTitle: "Сохранённые пайплайны — easy-png-tools",
|
||||||
|
metaDescription:
|
||||||
|
"Ваши сохранённые пайплайны изображений: именованные цепочки инструментов, хранятся локально в браузере.",
|
||||||
|
heading: "Сохранённые пайплайны",
|
||||||
|
empty:
|
||||||
|
"Пока нет сохранённых пайплайнов — добавьте шаг на странице любого инструмента, и он появится здесь.",
|
||||||
|
autoName: "Пайплайн {stamp}",
|
||||||
|
saveAs: "Сохранить как пайплайн",
|
||||||
|
newChain: "Новый пайплайн",
|
||||||
|
use: "Открыть",
|
||||||
|
rename: "Переименовать",
|
||||||
|
renameSave: "Сохранить имя",
|
||||||
|
deleteAction: "Удалить",
|
||||||
|
confirmDelete: "Точно?",
|
||||||
|
nameAria: "Название пайплайна",
|
||||||
|
},
|
||||||
sourceCard: {
|
sourceCard: {
|
||||||
source: "Источник",
|
source: "Источник",
|
||||||
replaceImage: "Заменить изображение",
|
replaceImage: "Заменить изображение",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Page } from "$lib/registry";
|
import type { Page } from "$lib/registry";
|
||||||
|
import { PAGES } from "../registry";
|
||||||
import type { Field } from "$lib/registry-schema";
|
import type { Field } from "$lib/registry-schema";
|
||||||
import { getMergedDict } from "./locale.svelte";
|
import { getMergedDict } from "./locale.svelte";
|
||||||
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
||||||
@@ -20,6 +21,12 @@ export function pageDescription(page: Page): string {
|
|||||||
return getMergedDict().pages?.[page.slug]?.description ?? page.description;
|
return getMergedDict().pages?.[page.slug]?.description ?? page.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Step title for chain UIs: the owning page title, else the raw tool id. */
|
||||||
|
export function chainStepTitle(toolId: string): string {
|
||||||
|
const owner = PAGES.find((p) => p.steps[0].id === toolId);
|
||||||
|
return owner ? pageTitle(owner) : toolId;
|
||||||
|
}
|
||||||
|
|
||||||
export function fieldLabel(field: Field<unknown>, id: string): string {
|
export function fieldLabel(field: Field<unknown>, id: string): string {
|
||||||
const key = (field.spec as { label?: string }).label;
|
const key = (field.spec as { label?: string }).label;
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ function uid(): string {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageKey(slug: string): string {
|
|
||||||
return `pipeline:${slug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function storage(): Storage | null {
|
|
||||||
return typeof localStorage === "undefined" ? null : localStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tool can be inserted mid-chain: it accepts and returns an image. */
|
/** Tool can be inserted mid-chain: it accepts and returns an image. */
|
||||||
export function isChainable(toolId: string): boolean {
|
export function isChainable(toolId: string): boolean {
|
||||||
const tool = getTool(toolId);
|
const tool = getTool(toolId);
|
||||||
@@ -88,27 +80,20 @@ export function parseChain(data: unknown): ChainStep[] | null {
|
|||||||
return out.length > 0 ? out : null;
|
return out.length > 0 ? out : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadChain(slug: string): ChainStep[] | null {
|
/** Serialized step shape shared by per-slug chains and named chains. */
|
||||||
const raw = storage()?.getItem(storageKey(slug));
|
export function serializeSteps(steps: ChainStep[]): Record<string, unknown>[] {
|
||||||
if (!raw) return null;
|
return steps.map((s) => ({
|
||||||
try {
|
|
||||||
return parseChain(JSON.parse(raw));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveChain(slug: string, steps: ChainStep[]): void {
|
|
||||||
storage()?.setItem(
|
|
||||||
storageKey(slug),
|
|
||||||
JSON.stringify({
|
|
||||||
version: CHAIN_VERSION,
|
|
||||||
steps: steps.map((s) => ({
|
|
||||||
id: s.id,
|
id: s.id,
|
||||||
params: s.params,
|
params: s.params,
|
||||||
...(s.collapsed ? { collapsed: true } : {}),
|
...(s.collapsed ? { collapsed: true } : {}),
|
||||||
})),
|
}));
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
/** True while steps match the page definition identity-wise (params ignored). */
|
||||||
|
export function isStructuralDefault(page: Page, steps: ChainStep[]): boolean {
|
||||||
|
return (
|
||||||
|
steps.length === page.steps.length &&
|
||||||
|
steps.every((s, i) => s.id === page.steps[i].id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
function toCrumb(path: string): string {
|
function toCrumb(path: string): string {
|
||||||
if (path === "/") return "/ " + t("header.home");
|
if (path === "/") return "/ " + t("header.home");
|
||||||
if (path === "/list-tools") return "/ " + t("header.catalog");
|
if (path === "/list-tools") return "/ " + t("header.catalog");
|
||||||
|
if (path === "/chains") return "/ " + t("header.chains");
|
||||||
|
if (path === "/pipeline") return "/ " + t("header.pipeline");
|
||||||
if (path === "/kit") return "/ " + t("header.uiKit");
|
if (path === "/kit") return "/ " + t("header.uiKit");
|
||||||
const match = /^\/tools\/([^/]+)$/.exec(path);
|
const match = /^\/tools\/([^/]+)$/.exec(path);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import { ButtonVariantDefine } from "$lib/components/ui/define";
|
||||||
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import ToolPickerButton from "$lib/components/schema/ToolPickerButton.svelte";
|
||||||
|
import {
|
||||||
|
autoChainName,
|
||||||
|
createNamedChain,
|
||||||
|
deleteChain,
|
||||||
|
listChains,
|
||||||
|
renameChain,
|
||||||
|
type NamedChain,
|
||||||
|
} from "$lib/chains.svelte";
|
||||||
|
import { chainStepTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { createChain } from "$lib/pipeline.svelte";
|
||||||
|
import { PAGES } from "$lib/registry";
|
||||||
|
import { Workflow as PipelineIcon } from "@lucide/svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let chains = $state<NamedChain[]>([]);
|
||||||
|
let renamingId = $state("");
|
||||||
|
let renameValue = $state("");
|
||||||
|
let confirmDeleteId = $state("");
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
chains = listChains();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
function useChain(id: string) {
|
||||||
|
goto(resolve(`/pipeline?id=${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(chain: NamedChain) {
|
||||||
|
renamingId = chain.id;
|
||||||
|
renameValue = chain.name;
|
||||||
|
confirmDeleteId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRename() {
|
||||||
|
renameChain(renamingId, renameValue);
|
||||||
|
renamingId = "";
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
if (confirmDeleteId !== id) {
|
||||||
|
confirmDeleteId = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteChain(id);
|
||||||
|
confirmDeleteId = "";
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNew(toolId: string) {
|
||||||
|
const owner = PAGES.find((p) => p.steps[0].id === toolId);
|
||||||
|
if (!owner) return;
|
||||||
|
const chain = createNamedChain(autoChainName(), createChain(owner));
|
||||||
|
goto(resolve(`/pipeline?id=${chain.id}`));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo
|
||||||
|
title={t("savedChains.pageTitle")}
|
||||||
|
description={t("savedChains.metaDescription")}
|
||||||
|
path="/chains"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="chains-page">
|
||||||
|
<header class="head">
|
||||||
|
<h1>{t("savedChains.heading")}</h1>
|
||||||
|
<ToolPickerButton label={t("savedChains.newChain")} onadd={addNew} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if chains.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
title={t("savedChains.heading")}
|
||||||
|
description={t("savedChains.empty")}
|
||||||
|
icon={PipelineIcon}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each chains as chain (chain.id)}
|
||||||
|
<li class="row">
|
||||||
|
<div class="info">
|
||||||
|
{#if renamingId === chain.id}
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
aria-label={t("savedChains.nameAria")}
|
||||||
|
bind:value={renameValue}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") saveRename();
|
||||||
|
if (e.key === "Escape") renamingId = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<strong class="name">{chain.name}</strong>
|
||||||
|
{/if}
|
||||||
|
<span class="steps">
|
||||||
|
{chain.steps.map((s) => chainStepTitle(s.id)).join(" → ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
{#if renamingId === chain.id}
|
||||||
|
<Button
|
||||||
|
label={t("savedChains.renameSave")}
|
||||||
|
variant={ButtonVariantDefine.OUTLINE}
|
||||||
|
size="s"
|
||||||
|
onclick={saveRename}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
label={t("savedChains.use")}
|
||||||
|
variant={ButtonVariantDefine.PRIMARY}
|
||||||
|
size="s"
|
||||||
|
onclick={() => useChain(chain.id)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("savedChains.rename")}
|
||||||
|
variant={ButtonVariantDefine.OUTLINE}
|
||||||
|
size="s"
|
||||||
|
onclick={() => startRename(chain)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={confirmDeleteId === chain.id
|
||||||
|
? t("savedChains.confirmDelete")
|
||||||
|
: t("savedChains.deleteAction")}
|
||||||
|
variant={ButtonVariantDefine.CLEAR}
|
||||||
|
size="s"
|
||||||
|
onclick={() => remove(chain.id)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chains-page {
|
||||||
|
max-width: var(--size-content-max-wide);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-page-top)
|
||||||
|
clamp(var(--space-xxl), 4vw, var(--space-page-bottom))
|
||||||
|
var(--space-page-bottom);
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-l);
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-l);
|
||||||
|
padding: var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-panel);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-s);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
}
|
||||||
|
.name-input {
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: var(--size-border) solid var(--color-main);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-m) var(--font-mono);
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@media (--bp-tablet) {
|
||||||
|
.row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page as pageState } from "$app/state";
|
||||||
|
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
||||||
|
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
|
||||||
|
import { getChain } from "$lib/chains.svelte";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { getTool, type Page } from "$lib/registry";
|
||||||
|
import { Workflow as PipelineIcon } from "@lucide/svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let mounted = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chainId = $derived(pageState.url.searchParams.get("id") ?? "");
|
||||||
|
const chain = $derived(mounted && chainId ? getChain(chainId) : undefined);
|
||||||
|
|
||||||
|
// Virtual page: a named chain is addressable on its own, the original
|
||||||
|
// tool page slug no longer applies (docs/backlog.md, idea 3).
|
||||||
|
const virtualPage = $derived<Page | undefined>(
|
||||||
|
chain
|
||||||
|
? {
|
||||||
|
slug: "pipeline",
|
||||||
|
title: chain.name,
|
||||||
|
description: "",
|
||||||
|
category: "convert",
|
||||||
|
steps: chain.steps.map((s) => ({ id: s.id, params: s.params })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const tool = $derived(
|
||||||
|
virtualPage ? getTool(virtualPage.steps[0].id) : undefined,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>easy-png-tools / {chain?.name ?? t("header.pipeline")}</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if mounted && (!chain || !virtualPage || !tool)}
|
||||||
|
<div class="notfound">
|
||||||
|
<EmptyState
|
||||||
|
title={t("errors.notFound")}
|
||||||
|
description={t("savedChains.empty")}
|
||||||
|
icon={PipelineIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if virtualPage && tool}
|
||||||
|
<SchemaToolView page={virtualPage} {tool} {chainId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notfound {
|
||||||
|
max-width: var(--size-content-max-wide);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-page-top)
|
||||||
|
clamp(var(--space-xxl), 4vw, var(--space-page-bottom))
|
||||||
|
var(--space-page-bottom);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user