feat: add chains page, update pipeline usage

This commit is contained in:
2026-10-08 20:24:12 +05:00
parent 4de8ec8190
commit a51e224d02
14 changed files with 682 additions and 71 deletions
+11 -8
View File
@@ -48,14 +48,17 @@ Coverage). Всё остальное в `core/**` — чистые функци
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
полей двух инструментов — отдельная работа, описанная как фаза 11
`docs/roadmap.md`. Под неё зарезервированы
`components/display/StepCard.svelte` (сейчас импортируется только витриной
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
`errors.pipelineVersion`, `errors.noSteps`.
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
`chains:named`). Страница инструмента всегда открывается чистой; при первом
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
фазы 11 `docs/roadmap.md`.
- **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
-10
View File
@@ -18,16 +18,6 @@
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
Именованные цепочки в localStorage (список, создать/переименовать/
удалить), быстрое переключение. Формат экспортного JSON расширить
опциональным полем имени; обратной совместимости со старыми выгрузками не
делаем — формат не устоялся: он появится вместе с фазой 11
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
со списком всех цепочек: удалить, переименовать, добавить новую,
«использовать» (переход на страницу инструмента с этой цепочкой). На
странице — упрощённый вид: только список инструментов цепочки, без
реального выполнения и параметров.
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
сохранённого chain по каждому файлу, сборка результата обратно в zip для
+153
View File
@@ -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;
}
+76
View File
@@ -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">
<a href={resolve("/")}>{t("header.home")}</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>
</nav>
@@ -1,5 +1,9 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
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 { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte";
@@ -20,22 +24,29 @@
import {
pageDescription,
pageTitle,
chainStepTitle,
verdictText,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import {
autoChainName,
createNamedChain,
getChain,
stashHandoff,
takeHandoff,
updateChainSteps,
} from "$lib/chains.svelte";
import {
createChain,
createStep,
insertStep,
loadChain,
isStructuralDefault,
moveStep,
removeStep,
saveChain,
type ChainStep,
} from "$lib/pipeline.svelte";
import {
getTool,
PAGES,
type FileResult,
type Page,
type Tool,
@@ -64,11 +75,13 @@
interface Props {
page: Page;
tool: Tool;
/** Named-chain mode: the chain id and display name; page is virtual. */
chainId?: string;
}
type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
let { page, tool }: Props = $props();
let { page, tool, chainId = undefined }: Props = $props();
const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
@@ -168,17 +181,29 @@
}
function stepTitle(toolId: string): string {
const owner = PAGES.find((p) => p.steps[0].id === toolId);
return owner ? pageTitle(owner) : toolId;
return chainStepTitle(toolId);
}
function init() {
if (!schema || page.slug === initedFor) return;
initedFor = page.slug;
steps = loadChain(page.slug) ?? createChain(page);
if (!schema) return;
const host = chainId ? `chain:${chainId}` : page.slug;
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";
limitKb = undefined;
source = null;
textSource = "";
}
result = null;
fileResult = null;
textResult = null;
@@ -439,11 +464,28 @@
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(() => {
if (!initedFor) return;
if (!initedFor || !chainId) return;
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(() => {
@@ -482,6 +524,18 @@
if (e instanceof Error) return { kind: "plain", text: e.message };
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>
{#if schema}
@@ -490,6 +544,7 @@
<div class="title-block">
<h1>
{pageTitle(page)}
{#if !chainId}
<button
type="button"
class="tool-fav"
@@ -498,11 +553,25 @@
aria-pressed={isFav(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>
{/if}
</h1>
{#if pageDescription(page)}
<p class="lede">{pageDescription(page)}</p>
{/if}
</div>
{#if !chainId}
<Button
label={t("savedChains.saveAs")}
variant={ButtonVariantDefine.OUTLINE}
size="s"
onclick={saveAsChain}
/>
{/if}
</header>
{#snippet stepCard(step: ChainStep, i: number)}
+3
View File
@@ -39,6 +39,8 @@ export type HeaderStrings = {
footerNote: string;
home: string;
uiKit: string;
chains: string;
pipeline: string;
};
export type Dict = {
@@ -48,6 +50,7 @@ export type Dict = {
catalog: Record<string, string>;
toolPage: Record<string, string>;
chain: Record<string, string>;
savedChains: Record<string, string>;
sourceCard: Record<string, string>;
resultCard: Record<string, string>;
paramsCard: Record<string, string>;
+19
View File
@@ -7,6 +7,8 @@ export const en: Dict = {
sectionsAria: "Sections",
home: "Home page",
uiKit: "UI Kit",
chains: "Pipelines",
pipeline: "Pipeline",
footerNote:
"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",
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: {
source: "Source",
replaceImage: "Replace image",
+19
View File
@@ -7,6 +7,8 @@ export const ru: Dict = {
sectionsAria: "Разделы",
home: "Главная",
uiKit: "UI Kit",
chains: "Пайплайны",
pipeline: "Пайплайн",
footerNote:
"Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.",
},
@@ -63,6 +65,23 @@ export const ru: Dict = {
busyHint: "Выполняется шаг цепочки",
removeStepAria: "Убрать шаг",
},
savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools",
metaDescription:
"Ваши сохранённые пайплайны изображений: именованные цепочки инструментов, хранятся локально в браузере.",
heading: "Сохранённые пайплайны",
empty:
"Пока нет сохранённых пайплайнов — добавьте шаг на странице любого инструмента, и он появится здесь.",
autoName: "Пайплайн {stamp}",
saveAs: "Сохранить как пайплайн",
newChain: "Новый пайплайн",
use: "Открыть",
rename: "Переименовать",
renameSave: "Сохранить имя",
deleteAction: "Удалить",
confirmDelete: "Точно?",
nameAria: "Название пайплайна",
},
sourceCard: {
source: "Источник",
replaceImage: "Заменить изображение",
+7
View File
@@ -1,4 +1,5 @@
import type { Page } from "$lib/registry";
import { PAGES } from "../registry";
import type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte";
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;
}
/** 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 {
const key = (field.spec as { label?: string }).label;
if (key) {
+11 -26
View File
@@ -14,14 +14,6 @@ function uid(): string {
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. */
export function isChainable(toolId: string): boolean {
const tool = getTool(toolId);
@@ -88,27 +80,20 @@ export function parseChain(data: unknown): ChainStep[] | null {
return out.length > 0 ? out : null;
}
export function loadChain(slug: string): ChainStep[] | null {
const raw = storage()?.getItem(storageKey(slug));
if (!raw) return null;
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) => ({
/** Serialized step shape shared by per-slug chains and named chains. */
export function serializeSteps(steps: ChainStep[]): Record<string, unknown>[] {
return steps.map((s) => ({
id: s.id,
params: s.params,
...(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)
);
}
+2
View File
@@ -40,6 +40,8 @@
function toCrumb(path: string): string {
if (path === "/") return "/ " + t("header.home");
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");
const match = /^\/tools\/([^/]+)$/.exec(path);
if (match) {
+221
View File
@@ -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>
+63
View File
@@ -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>