mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: update tool page - add step card steps
This commit is contained in:
@@ -332,7 +332,7 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
||||||
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
||||||
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
|
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
|
||||||
- [ ] **[C14]** step-cards для chain в tool-page.
|
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги (параметры inline) + «Add step» (chainable), живой прогон пайплайна на исходнике, превью каждого шага + итог + Download.
|
||||||
|
|
||||||
### Шаг 4. Переключатель и проверка
|
### Шаг 4. Переключатель и проверка
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { getTool, defaultParams } from "$lib/registry";
|
import {
|
||||||
|
getTool,
|
||||||
|
defaultParams,
|
||||||
|
isChainable,
|
||||||
|
TOOLS,
|
||||||
|
type ParamDef,
|
||||||
|
} from "$lib/registry";
|
||||||
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
|
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import Panel from "$lib/components/kit/Panel.svelte";
|
import Panel from "$lib/components/kit/Panel.svelte";
|
||||||
@@ -21,94 +27,134 @@
|
|||||||
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
||||||
import Button from "$lib/components/kit/Button.svelte";
|
import Button from "$lib/components/kit/Button.svelte";
|
||||||
import Dropzone from "$lib/components/kit/Dropzone.svelte";
|
import Dropzone from "$lib/components/kit/Dropzone.svelte";
|
||||||
|
import StepCard from "$lib/components/kit/StepCard.svelte";
|
||||||
|
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
|
||||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||||
|
|
||||||
type ParamValue = string | number | boolean;
|
type ParamValue = string | number | boolean;
|
||||||
|
|
||||||
|
interface ChainStep {
|
||||||
|
toolId: string;
|
||||||
|
params: Record<string, ParamValue>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: { id: string };
|
data: { id: string };
|
||||||
}
|
}
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const initialTool = untrack(() => getTool(data.id));
|
const initialTool = untrack(() => getTool(data.id));
|
||||||
let params = $state<Record<string, ParamValue>>(
|
let steps = $state<ChainStep[]>(
|
||||||
initialTool
|
initialTool
|
||||||
? (defaultParams(initialTool) as Record<string, ParamValue>)
|
? [
|
||||||
: {},
|
{
|
||||||
|
toolId: initialTool.id,
|
||||||
|
params: defaultParams(initialTool) as Record<string, ParamValue>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
let previewUrl = $state("");
|
|
||||||
let previewError = $state("");
|
|
||||||
|
|
||||||
let sourceImg = $state<PixelImage | null>(null);
|
let sourceImg = $state<PixelImage | null>(null);
|
||||||
let sourceUrl = $state("");
|
let sourceUrl = $state("");
|
||||||
let sourceName = $state("");
|
let sourceName = $state("");
|
||||||
|
|
||||||
|
let stepOutputs = $state<{ toolId: string; title: string; url: string }[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
let resultImg = $state<PixelImage | null>(null);
|
let resultImg = $state<PixelImage | null>(null);
|
||||||
let resultUrl = $state("");
|
let resultUrl = $state("");
|
||||||
let showMask = $state(false);
|
let chainError = $state("");
|
||||||
|
let processing = $state(false);
|
||||||
|
|
||||||
const tool = $derived(getTool(data.id));
|
const chainOptions = TOOLS.filter(isChainable).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: t.title,
|
||||||
|
}));
|
||||||
|
let addId = $state(chainOptions[0]?.value ?? "");
|
||||||
|
|
||||||
|
const firstTool = $derived(steps[0] ? getTool(steps[0].toolId) : undefined);
|
||||||
|
const needsSource = $derived(
|
||||||
|
firstTool ? firstTool.sourceMode !== "none" : false,
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = getTool(data.id);
|
const t = getTool(data.id);
|
||||||
if (next) params = defaultParams(next) as Record<string, ParamValue>;
|
if (t) {
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
toolId: t.id,
|
||||||
|
params: defaultParams(t) as Record<string, ParamValue>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
sourceImg = null;
|
||||||
|
sourceUrl = "";
|
||||||
|
sourceName = "";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!tool || !tool.generate) {
|
|
||||||
previewUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const snapshot = { ...params };
|
const chain = steps.map((s) => ({
|
||||||
try {
|
tool: getTool(s.toolId),
|
||||||
const result = tool.generate(snapshot);
|
params: { ...s.params },
|
||||||
if (result instanceof Promise) {
|
}));
|
||||||
result
|
|
||||||
.then((img) => {
|
|
||||||
previewUrl = toDataUrl(img);
|
|
||||||
previewError = "";
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
previewError = String(e);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
previewUrl = toDataUrl(result);
|
|
||||||
previewError = "";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
previewError = String(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const src = sourceImg;
|
const src = sourceImg;
|
||||||
if (!tool || !tool.run || !src) {
|
if (!chain.length || chain.some((c) => !c.tool)) {
|
||||||
resultImg = null;
|
resultImg = null;
|
||||||
resultUrl = "";
|
resultUrl = "";
|
||||||
|
stepOutputs = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!browser) return;
|
const first = chain[0].tool!;
|
||||||
const snapshot = { ...params };
|
if (first.sourceMode !== "none" && !src) {
|
||||||
const mask = showMask && !!tool.preview;
|
resultImg = null;
|
||||||
try {
|
resultUrl = "";
|
||||||
const base = mask
|
stepOutputs = [];
|
||||||
? tool.preview!(src, snapshot)
|
return;
|
||||||
: tool.run(src, snapshot);
|
|
||||||
const apply = (img: PixelImage) => {
|
|
||||||
resultImg = img;
|
|
||||||
resultUrl = toDataUrl(img);
|
|
||||||
previewError = "";
|
|
||||||
};
|
|
||||||
if (base instanceof Promise) {
|
|
||||||
base.then(apply).catch((e) => (previewError = String(e)));
|
|
||||||
} else {
|
|
||||||
apply(base);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
previewError = String(e);
|
|
||||||
}
|
}
|
||||||
|
void runPipeline(chain, src);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function runPipeline(
|
||||||
|
chain: {
|
||||||
|
tool: ReturnType<typeof getTool>;
|
||||||
|
params: Record<string, ParamValue>;
|
||||||
|
}[],
|
||||||
|
src: PixelImage | null,
|
||||||
|
) {
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
let current: PixelImage | null = src;
|
||||||
|
const outs: { toolId: string; title: string; url: string }[] = [];
|
||||||
|
for (const c of chain) {
|
||||||
|
const tool = c.tool!;
|
||||||
|
const resolve = (x: PixelImage | Promise<PixelImage>) =>
|
||||||
|
x instanceof Promise ? x : Promise.resolve(x);
|
||||||
|
let out: PixelImage;
|
||||||
|
if (!current && tool.sourceMode === "none" && tool.generate) {
|
||||||
|
out = await resolve(tool.generate(c.params));
|
||||||
|
} else if (current && tool.run) {
|
||||||
|
out = await resolve(tool.run(current, c.params));
|
||||||
|
} else if (!current && src && tool.run) {
|
||||||
|
out = await resolve(tool.run(src, c.params));
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current = out;
|
||||||
|
outs.push({ toolId: tool.id, title: tool.title, url: toDataUrl(out) });
|
||||||
|
}
|
||||||
|
stepOutputs = outs;
|
||||||
|
resultImg = current;
|
||||||
|
resultUrl = current ? toDataUrl(current) : "";
|
||||||
|
chainError = "";
|
||||||
|
} catch (e) {
|
||||||
|
chainError = String(e);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onFile(file: File) {
|
async function onFile(file: File) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
try {
|
try {
|
||||||
@@ -117,9 +163,9 @@
|
|||||||
sourceImg = img;
|
sourceImg = img;
|
||||||
sourceName = file.name;
|
sourceName = file.name;
|
||||||
sourceUrl = toDataUrl(img);
|
sourceUrl = toDataUrl(img);
|
||||||
previewError = "";
|
chainError = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
previewError = String(e);
|
chainError = String(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,126 +173,106 @@
|
|||||||
sourceImg = null;
|
sourceImg = null;
|
||||||
sourceUrl = "";
|
sourceUrl = "";
|
||||||
sourceName = "";
|
sourceName = "";
|
||||||
resultImg = null;
|
|
||||||
resultUrl = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function download() {
|
function setStepParam(index: number, id: string, value: ParamValue) {
|
||||||
if (!tool?.generate) return;
|
steps[index].params[id] = value;
|
||||||
const result = tool.generate({ ...params });
|
}
|
||||||
const img = result instanceof Promise ? await result : result;
|
|
||||||
const blob = await encode(img, "image/png");
|
function addStep() {
|
||||||
downloadBlob(blob, `${tool.id}.png`);
|
const tool = getTool(addId);
|
||||||
|
if (!tool) return;
|
||||||
|
steps = [
|
||||||
|
...steps,
|
||||||
|
{
|
||||||
|
toolId: tool.id,
|
||||||
|
params: defaultParams(tool) as Record<string, ParamValue>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(index: number) {
|
||||||
|
steps = steps.filter((_, i) => i !== index);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadResult() {
|
async function downloadResult() {
|
||||||
if (!resultImg || !tool) return;
|
if (!resultImg || !firstTool) return;
|
||||||
const blob = await encode(resultImg, "image/png");
|
const blob = await encode(resultImg, "image/png");
|
||||||
const base = sourceName.replace(/\.[^.]+$/, "");
|
const base = sourceName ? sourceName.replace(/\.[^.]+$/, "") : firstTool.id;
|
||||||
downloadBlob(blob, `${base}-${tool.id}.png`);
|
downloadBlob(blob, `${base}-pipeline.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setParam(id: string, value: ParamValue) {
|
const resultMeta = $derived(
|
||||||
params[id] = value;
|
resultImg
|
||||||
}
|
|
||||||
|
|
||||||
const isFileTool = $derived(
|
|
||||||
tool ? tool.sourceMode === "file" || tool.sourceMode === undefined : false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
tool
|
|
||||||
? [
|
? [
|
||||||
{ caption: "TOOL", value: tool.id },
|
{ caption: "STEPS", value: String(steps.length) },
|
||||||
{ caption: "CATEGORY", value: tool.category.toUpperCase() },
|
{
|
||||||
...(tool.sourceMode === "none" &&
|
caption: "DIMENSIONS",
|
||||||
"width" in params &&
|
value: `${resultImg.width} × ${resultImg.height} px`,
|
||||||
"height" in params
|
},
|
||||||
? [
|
{ caption: "FORMAT", value: "PNG-24" },
|
||||||
{
|
|
||||||
caption: "OUTPUT",
|
|
||||||
value: `${params.width} × ${params.height} px`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>easy-png-tools / {tool?.title ?? "Tool"}</title>
|
<title>easy-png-tools / {firstTool?.title ?? "Tool"}</title>
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#snippet settingsPanel()}
|
{#snippet control(
|
||||||
<Panel>
|
p: ParamDef,
|
||||||
<PanelHeading title={tool!.title} eyebrow={tool!.category}>
|
value: ParamValue,
|
||||||
{#snippet actions()}
|
onInput: (v: ParamValue) => void,
|
||||||
<Badge tone="accent">AUTO</Badge>
|
)}
|
||||||
{/snippet}
|
{#if p.type === "color"}
|
||||||
</PanelHeading>
|
<ColorField
|
||||||
<p class="tool-desc">{tool!.description}</p>
|
label={p.label}
|
||||||
<div class="settings-body">
|
value={value as string}
|
||||||
{#each tool!.params as p (p.id)}
|
oninput={(v) => onInput(v)}
|
||||||
<div class="setting-row">
|
/>
|
||||||
{#if p.type === "color"}
|
{:else if p.type === "slider"}
|
||||||
<ColorField
|
<SliderField
|
||||||
label={p.label}
|
label={p.label}
|
||||||
value={params[p.id] as string}
|
value={value as number}
|
||||||
oninput={(v) => setParam(p.id, v)}
|
min={p.min}
|
||||||
/>
|
max={p.max}
|
||||||
{:else if p.type === "slider"}
|
step={p.step}
|
||||||
<SliderField
|
oninput={(v) => onInput(v)}
|
||||||
label={p.label}
|
/>
|
||||||
value={params[p.id] as number}
|
{:else if p.type === "select"}
|
||||||
min={p.min}
|
<SelectField
|
||||||
max={p.max}
|
label={p.label}
|
||||||
step={p.step}
|
value={value as string}
|
||||||
oninput={(v) => setParam(p.id, v)}
|
options={p.options}
|
||||||
/>
|
onchange={(v) => onInput(v)}
|
||||||
{:else if p.type === "select"}
|
/>
|
||||||
<SelectField
|
{:else if p.type === "checkbox"}
|
||||||
label={p.label}
|
<label class="toggle-row"
|
||||||
value={params[p.id] as string}
|
><span>{p.label}</span>
|
||||||
options={p.options}
|
<Toggle checked={value as boolean} onchange={(v) => onInput(v)} /></label
|
||||||
onchange={(v) => setParam(p.id, v)}
|
>
|
||||||
/>
|
{:else if p.type === "text"}
|
||||||
{:else if p.type === "checkbox"}
|
<TextField
|
||||||
<label class="toggle-row"
|
label={p.label}
|
||||||
><span>{p.label}</span>
|
value={value as string}
|
||||||
<Toggle
|
placeholder={p.placeholder}
|
||||||
checked={params[p.id] as boolean}
|
oninput={(v) => onInput(v)}
|
||||||
onchange={(v) => setParam(p.id, v)}
|
/>
|
||||||
/></label
|
{:else if p.type === "number"}
|
||||||
>
|
<NumberField
|
||||||
{:else if p.type === "text"}
|
label={p.label}
|
||||||
<TextField
|
value={value as number}
|
||||||
label={p.label}
|
min={p.min}
|
||||||
value={params[p.id] as string}
|
max={p.max}
|
||||||
placeholder={p.placeholder}
|
step={p.step}
|
||||||
oninput={(v) => setParam(p.id, v)}
|
oninput={(v) => onInput(v)}
|
||||||
/>
|
/>
|
||||||
{:else if p.type === "number"}
|
{/if}
|
||||||
<NumberField
|
|
||||||
label={p.label}
|
|
||||||
value={params[p.id] as number}
|
|
||||||
min={p.min}
|
|
||||||
max={p.max}
|
|
||||||
step={p.step}
|
|
||||||
oninput={(v) => setParam(p.id, v)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<SettingsFooter>
|
|
||||||
<StatusLine label="changes applied automatically" />
|
|
||||||
</SettingsFooter>
|
|
||||||
</Panel>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if !tool}
|
{#if !firstTool}
|
||||||
<div class="tool-grid">
|
<div class="tool-grid">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Tool not found"
|
title="Tool not found"
|
||||||
@@ -254,45 +280,59 @@
|
|||||||
icon={ToolIcon}
|
icon={ToolIcon}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if tool.sourceMode === "none"}
|
{:else}
|
||||||
<div class="tool-grid">
|
<div class="tool-grid">
|
||||||
<section class="settings-panel">{@render settingsPanel()}</section>
|
<section class="pipeline-panel">
|
||||||
<section class="preview-panel">
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeading title="Preview" eyebrow="OUTPUT">
|
<PanelHeading title="Pipeline" eyebrow="STEPS">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<DownloadButton label="Download PNG" onclick={download} />
|
<Badge tone="accent">AUTO</Badge>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PanelHeading>
|
</PanelHeading>
|
||||||
<div class="preview-body">
|
<div class="pipeline-body">
|
||||||
{#if previewError}
|
{#if steps.length === 0}
|
||||||
<EmptyState title="Preview failed" description={previewError} />
|
|
||||||
{:else if previewUrl}
|
|
||||||
<CheckerCanvas size="large">
|
|
||||||
<img class="result-img" src={previewUrl} alt="result" />
|
|
||||||
</CheckerCanvas>
|
|
||||||
{:else if tool.generate}
|
|
||||||
<div class="preview-loading">
|
|
||||||
<StatusLine label="generating preview" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No live preview"
|
title="Pipeline empty"
|
||||||
description="This tool runs on an uploaded image."
|
description="Add a tool step below."
|
||||||
icon={ToolIcon}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<MetaList items={meta} />
|
{#each steps as step, i (step.toolId + i)}
|
||||||
|
{@const st = getTool(step.toolId)}
|
||||||
|
<StepCard
|
||||||
|
index={i + 1}
|
||||||
|
title={st?.title ?? step.toolId}
|
||||||
|
type={st?.category}
|
||||||
|
onremove={() => removeStep(i)}
|
||||||
|
>
|
||||||
|
{#if st}
|
||||||
|
<div class="step-controls">
|
||||||
|
{#each st.params as p (p.id)}
|
||||||
|
{@render control(p, step.params[p.id] as ParamValue, (v) =>
|
||||||
|
setStepParam(i, p.id, v),
|
||||||
|
)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</StepCard>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsFooter>
|
||||||
|
<div class="add-step">
|
||||||
|
<SelectField
|
||||||
|
label="Add step"
|
||||||
|
value={addId}
|
||||||
|
options={chainOptions}
|
||||||
|
onchange={(v) => (addId = v)}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" onclick={addStep}>Add step</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsFooter>
|
||||||
</Panel>
|
</Panel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
{:else if isFileTool}
|
|
||||||
<div class="tool-grid remover-grid">
|
|
||||||
<section class="settings-panel">{@render settingsPanel()}</section>
|
|
||||||
<section class="preview-panel">
|
<section class="preview-panel">
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeading title="Source / Result" eyebrow="OUTPUT">
|
<PanelHeading title="Pipeline output" eyebrow="RESULT">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if resultUrl}
|
{#if resultUrl}
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
@@ -303,69 +343,34 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PanelHeading>
|
</PanelHeading>
|
||||||
<div class="preview-body">
|
<div class="preview-body">
|
||||||
{#if previewError}
|
{#if chainError}
|
||||||
<EmptyState title="Processing failed" description={previewError} />
|
<EmptyState title="Pipeline failed" description={chainError} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !sourceImg}
|
{#if needsSource && !sourceImg}
|
||||||
<Dropzone onfile={onFile} />
|
<Dropzone onfile={onFile} />
|
||||||
{:else}
|
{:else if needsSource && sourceImg}
|
||||||
<div class="comparison-grid">
|
<Button variant="ghost" onclick={clearSource}>Change image</Button>
|
||||||
<div class="image-card">
|
|
||||||
<div class="image-label">
|
|
||||||
<span>SOURCE</span><b>{sourceName}</b>
|
|
||||||
</div>
|
|
||||||
<div class="remover-canvas">
|
|
||||||
<CheckerCanvas size="large">
|
|
||||||
<img class="cmp-img" src={sourceUrl} alt="source" />
|
|
||||||
</CheckerCanvas>
|
|
||||||
<span class="canvas-size"
|
|
||||||
>{sourceImg.width} × {sourceImg.height}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="image-card">
|
|
||||||
<div class="image-label">
|
|
||||||
<span>RESULT</span><b>{sourceName}</b>
|
|
||||||
</div>
|
|
||||||
<div class="remover-canvas">
|
|
||||||
{#if resultUrl}
|
|
||||||
<CheckerCanvas size="large">
|
|
||||||
<img class="cmp-img" src={resultUrl} alt="result" />
|
|
||||||
</CheckerCanvas>
|
|
||||||
{/if}
|
|
||||||
<span class="canvas-size"
|
|
||||||
>{resultImg
|
|
||||||
? `${resultImg.width} × ${resultImg.height}`
|
|
||||||
: "—"}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="result-meta">
|
|
||||||
<span>FORMAT <b>PNG-24</b></span>
|
|
||||||
<span>ALPHA <b>ENABLED</b></span>
|
|
||||||
<span>STATUS <b class="ok">PROCESSED</b></span>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" onclick={clearSource}
|
|
||||||
>Change image</Button
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<div class="preview-stack">
|
||||||
</Panel>
|
{#if needsSource && sourceImg}
|
||||||
</section>
|
<PreviewTile label="SOURCE">
|
||||||
</div>
|
<CheckerCanvas size="sm">
|
||||||
{:else}
|
<img class="tile-img" src={sourceUrl} alt="source" />
|
||||||
<div class="tool-grid">
|
</CheckerCanvas>
|
||||||
<section class="settings-panel">{@render settingsPanel()}</section>
|
</PreviewTile>
|
||||||
<section class="preview-panel">
|
{/if}
|
||||||
<Panel>
|
{#each stepOutputs as out, i (out.toolId + i)}
|
||||||
<PanelHeading title="Preview" eyebrow="OUTPUT" />
|
<PreviewTile label={`STEP ${String(i + 1).padStart(2, "0")}`}>
|
||||||
<div class="preview-body">
|
<CheckerCanvas size="sm">
|
||||||
<EmptyState
|
<img class="tile-img" src={out.url} alt={out.title} />
|
||||||
title="Text source"
|
</CheckerCanvas>
|
||||||
description="Paste text on the left — arrives in a later step."
|
</PreviewTile>
|
||||||
icon={ToolIcon}
|
{/each}
|
||||||
/>
|
</div>
|
||||||
|
{#if processing}
|
||||||
|
<StatusLine label="processing pipeline" />
|
||||||
|
{/if}
|
||||||
|
<MetaList items={resultMeta} />
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</section>
|
</section>
|
||||||
@@ -375,31 +380,23 @@
|
|||||||
<style>
|
<style>
|
||||||
.tool-grid {
|
.tool-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
max-width: 1680px;
|
max-width: 1680px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px clamp(24px, 4vw, 72px) 72px;
|
padding: 48px clamp(24px, 4vw, 72px) 72px;
|
||||||
}
|
}
|
||||||
.remover-grid {
|
.pipeline-body {
|
||||||
grid-template-columns: minmax(360px, 0.62fr) minmax(0, 1.38fr);
|
|
||||||
}
|
|
||||||
.tool-desc {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.settings-body {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
.setting-row {
|
.step-controls {
|
||||||
padding: 0.7rem 1rem;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--line);
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -410,6 +407,16 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.add-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.add-step :global(.select-field),
|
||||||
|
.add-step :global(select) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.preview-panel {
|
.preview-panel {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 84px;
|
top: 84px;
|
||||||
@@ -420,82 +427,19 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.preview-loading {
|
.preview-stack {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.result-img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 360px;
|
|
||||||
display: block;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
.comparison-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.image-card {
|
.tile-img {
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.image-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font: 10px var(--font-mono);
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.image-label span {
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
.image-label b {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.remover-canvas {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cmp-img {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 420px;
|
max-height: 220px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
|
||||||
.canvas-size {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 9px;
|
|
||||||
right: 10px;
|
|
||||||
font: 9px var(--font-mono);
|
|
||||||
color: var(--muted);
|
|
||||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.result-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
font: 10px var(--font-mono);
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.result-meta b {
|
|
||||||
margin-left: 6px;
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.result-meta b.ok {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.tool-grid,
|
.tool-grid {
|
||||||
.remover-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 32px 16px 48px;
|
padding: 32px 16px 48px;
|
||||||
@@ -503,8 +447,5 @@
|
|||||||
.preview-panel {
|
.preview-panel {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
.comparison-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user