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
+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);
format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null;
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,19 +544,34 @@
<div class="title-block">
<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>
{#if !chainId}
<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>
{/if}
</h1>
<p class="lede">{pageDescription(page)}</p>
{#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) {
+12 -27
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;
}
/** 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 } : {}),
}));
}
export function saveChain(slug: string, steps: ChainStep[]): void {
storage()?.setItem(
storageKey(slug),
JSON.stringify({
version: CHAIN_VERSION,
steps: 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)
);
}