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:
@@ -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">
|
||||
<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)}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Заменить изображение",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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