refactor: separate components

This commit is contained in:
2026-10-04 08:48:14 +05:00
parent f89811c116
commit c4eab38866
63 changed files with 529 additions and 645 deletions
@@ -0,0 +1,95 @@
<script lang="ts">
import { Upload } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
interface Props {
accept?: string;
onfile?: (file: File) => void;
}
let { accept = "image/*", onfile }: Props = $props();
let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null);
function handleFiles(files: FileList | null | undefined) {
const file = files?.[0];
if (file) onfile?.(file);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
handleFiles(e.dataTransfer?.files);
}
function open() {
input?.click();
}
</script>
<div
class="dropzone"
class:dragging
role="button"
tabindex="0"
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
onclick={open}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
>
<input
bind:this={input}
type="file"
{accept}
hidden
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
/>
<Upload size={22} />
<span class="dz-title">{t("dropZone.pickTitle")}</span>
<span class="dz-sub">{t("dropZone.pickHint")}</span>
</div>
<style>
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-m);
min-height: var(--size-panel-min-height);
padding: var(--space-xxxl);
border: var(--size-border) dashed var(--color-border);
border-radius: var(--radius-m);
color: var(--color-text-muted);
background: var(--color-background);
cursor: pointer;
text-align: center;
transition:
border-color var(--duration-s) ease,
background var(--duration-s) ease;
}
.dropzone:hover,
.dropzone:focus-visible,
.dropzone.dragging {
border-color: var(--color-main);
color: var(--color-text);
outline: none;
}
.dz-title {
font: 600 var(--font-size-m) var(--font-mono);
color: var(--color-text);
}
.dz-sub {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
</style>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { InputMode, ResultKind } from "$lib/registry";
import { RefreshCw } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte";
interface Props {
label: string;
viewMode: InputMode | ResultKind;
children: Snippet;
loading?: boolean;
parts?: number;
}
let {
label,
viewMode: mode,
children,
loading = false,
parts = undefined,
}: Props = $props();
</script>
<figure class="tile">
<figcaption>
<span>
{label}
{#if parts !== undefined}
<span class="count">{parts} {t("resultCard.parts")}</span>
{/if}
{#if loading}
<RefreshCw class="rotating" size="12" />
{/if}
</span>
</figcaption>
<div class="canvas" class:checker={mode === "image"}>
{@render children()}
</div>
</figure>
<style>
.tile figcaption {
display: flex;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
text-transform: uppercase;
color: var(--color-text-muted);
& :global(.rotating) {
animation: rotate var(--duration-l) linear infinite;
}
}
.count {
margin-left: var(--space-m);
color: var(--color-main);
}
.canvas {
display: flex;
align-items: center;
justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-background-muted);
color: var(--color-text-muted);
}
.canvas :global(img) {
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
object-fit: contain;
display: block;
}
.canvas.checker {
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 28px 28px;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,37 @@
<script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte";
import { t } from "$lib/i18n/t";
interface Props {
inputMode: "image" | "text" | "none";
canDownload: boolean;
running: boolean;
onupload: (file: File) => void;
ondownload: () => void;
}
let { inputMode, canDownload, running, onupload, ondownload }: Props =
$props();
</script>
<div class="actions">
{#if inputMode === "image"}
<UploadButton label={t("actions.openImage")} onfile={onupload} />
{/if}
{#if canDownload}
<DownloadButton
label={t("actions.downloadResult")}
onclick={ondownload}
disabled={running}
/>
{/if}
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: var(--space-l);
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,133 @@
<script lang="ts">
import type {
FieldSpec,
FieldSpecKind,
ToolSchema,
} from "$lib/registry-schema";
import { resolveLayoutGroups } from "$lib/registry-schema";
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte";
import type { Component } from "svelte";
import Button from "$lib/components/ui/Button.svelte";
import DimensionField from "$lib/components/fields/schema/DimensionField.svelte";
import CheckboxControl from "$lib/components/fields/schema/CheckboxControl.svelte";
import ColorControl from "$lib/components/fields/schema/ColorControl.svelte";
import ColorPairControl from "$lib/components/fields/schema/ColorPairControl.svelte";
import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
import SelectControl from "$lib/components/fields/schema/SelectControl.svelte";
import TextControl from "$lib/components/fields/schema/TextControl.svelte";
import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
import FontStyleControl from "$lib/components/fields/schema/FontStyleControl.svelte";
import PlateControl from "$lib/components/fields/schema/PlateControl.svelte";
import GradientControl from "$lib/components/fields/schema/GradientControl.svelte";
interface FieldControlProps {
label: string;
value: unknown;
spec: FieldSpec;
toolId: string;
fieldId: string;
onchange?: (value: unknown) => void;
}
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
number: RangeControl,
slider: RangeControl,
color: ColorControl,
"color-pair": ColorPairControl,
colors: ColorsControl,
select: SelectControl,
checkbox: CheckboxControl,
text: TextControl,
dimension: DimensionField,
offset: OffsetControl,
position9: PositionControl,
"font-style": FontStyleControl,
plate: PlateControl,
gradient: GradientControl,
};
interface Props {
schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>;
toolId: string;
onchange: (id: string, value: unknown) => void;
onreset: () => void;
}
let { schema, values, toolId, onchange, onreset }: Props = $props();
const layoutGroups = $derived(resolveLayoutGroups(schema));
</script>
{#each layoutGroups as group (group.key)}
{#if group.title}
<span class="group-title">{groupLabel(group.title)}</span>
{/if}
<div
class="group-fields"
style:grid-template-columns={group.cols > 1
? `repeat(${group.cols}, minmax(0, 1fr))`
: undefined}
>
{#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]}
<Control
label={fieldLabel(schema.fields[id], id)}
value={values[id]}
spec={schema.fields[id].spec}
{toolId}
fieldId={id}
onchange={(v) => onchange(id, v)}
/>
{/each}
</div>
{/each}
<div class="panel-foot">
<Button
icon={RotateCcw}
label={t("ui.reset")}
variant="clear"
size="s"
onclick={onreset}
/>
<span class="auto-note"><i></i> {t("ui.autoUpdate")}</span>
</div>
<style>
.group-title {
display: block;
margin-top: var(--space-l);
margin-bottom: var(--space-s);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.group-title:first-child {
margin-top: 0;
}
.group-fields {
display: grid;
grid-template-columns: 1fr;
column-gap: var(--space-l);
}
.panel-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
margin-top: var(--space-xl);
padding-top: var(--space-l);
border-top: var(--size-border) solid var(--color-border);
}
.auto-note i {
width: var(--size-border-thick);
height: var(--size-border-thick);
border-radius: 50%;
background: var(--color-success);
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import MetaList from "$lib/components/display/MetaList.svelte";
import { buildSchemaPreviewModel } from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
interface Props {
inputMode: InputMode;
resultKind?: ResultKind;
toolId: string;
source: PixelImage | null;
result: PixelImage | null;
fileResult?: FileResult | null;
textSource?: string;
textResult?: string | null;
textVars?: Record<string, string | number>;
running?: boolean;
error?: string;
onupload: (file: File) => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
ondownloadtxt?: () => void;
ondownload: () => void;
}
let {
inputMode,
resultKind = "image",
toolId,
source,
result,
fileResult = null,
textSource = "",
textResult = null,
textVars = undefined,
running = false,
error = "",
onupload,
ontextsource,
onrendertext,
oncopytext,
ondownloadtxt,
ondownload,
}: Props = $props();
const model = $derived(
buildSchemaPreviewModel(
{ inputMode, resultKind, source, result, fileResult, textResult },
t,
),
);
</script>
<div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span>
<SchemaActions
{inputMode}
canDownload={model.hasResult}
{running}
{onupload}
{ondownload}
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="pair">
<SchemaSourceTile
mode={inputMode}
{source}
{textSource}
{running}
ontextinput={ontextsource}
{onrendertext}
/>
<SchemaResultTile
{resultKind}
{result}
{fileResult}
{textResult}
{textVars}
{toolId}
{running}
oncopy={oncopytext}
{ondownloadtxt}
/>
</div>
<div class="meta">
<MetaList
items={[
{ caption: t("sourceCard.source"), value: model.sourceValue },
{ caption: t("resultCard.result"), value: model.resultValue },
{ caption: t("resultCard.format"), value: model.formatValue },
]}
/>
</div>
<style>
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.error {
margin: 0 0 var(--space-l);
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.meta {
margin-top: var(--space-l);
}
@media (--bp-tablet) {
.pair {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,172 @@
<script lang="ts">
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind } from "$lib/registry";
import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte";
interface Props {
resultKind?: ResultKind;
result: PixelImage | null;
fileResult?: FileResult | null;
textResult?: string | null;
textVars?: Record<string, string | number>;
toolId?: string;
running?: boolean;
oncopy?: () => void;
ondownloadtxt?: () => void;
}
let {
resultKind = "image",
result,
fileResult = null,
textResult = null,
textVars = undefined,
toolId = "",
running = false,
oncopy,
ondownloadtxt,
}: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null);
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
name: file.name,
url: toDataUrl(file.image),
}))
: [],
);
let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
);
</script>
<PreviewTile
label={t("resultCard.result")}
viewMode={resultKind}
loading={running}
{parts}
>
<div class="canvas" class:checker={resultKind === "image"}>
{#if resultKind === "text" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="text"
oncopy={oncopy ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "verdict" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="verdict"
vars={textVars}
{toolId}
oncopy={oncopy ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "files" && partUrls.length > 0}
<div class="parts-grid">
{#each partUrls as part (part.name)}
<figure class="part">
<img src={part.url} alt={part.name} loading="lazy" />
<figcaption>{part.name}</figcaption>
</figure>
{/each}
</div>
{:else if resultKind === "image" && resultUrl}
<img
data-testid="result-image"
src={resultUrl}
alt={t("resultCard.alt")}
/>
{:else if running}
<Check size={22} />
{:else}
<span data-testid="empty-state" class="empty"
>{t("resultCard.noResult")}</span
>
{/if}
</div>
</PreviewTile>
<style>
.canvas {
display: flex;
align-items: center;
justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: auto;
background: var(--color-background-muted);
color: var(--color-text-muted);
}
.canvas img {
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
object-fit: contain;
display: block;
}
.canvas.checker {
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 28px 28px;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.parts-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(var(--size-parts-grid-min), 1fr)
);
gap: var(--space-m);
width: 100%;
height: 100%;
padding: var(--space-l);
box-sizing: border-box;
align-content: start;
}
.part {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.part img {
width: 100%;
height: auto;
display: block;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 16px 16px;
}
.part figcaption {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
text-align: center;
overflow-wrap: anywhere;
}
.text-result-wrap {
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { InputMode } from "$lib/registry";
import PreviewTile from "./PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props {
mode: InputMode;
source: PixelImage | null;
textSource?: string;
running?: boolean;
ontextinput?: (text: string) => void;
onrendertext?: () => void;
}
let {
mode,
source,
textSource = "",
running = false,
ontextinput,
onrendertext,
}: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null);
</script>
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
value={textSource}
disabled={running}
oninput={ontextinput ?? (() => {})}
onrender={onrendertext ?? (() => {})}
/>
</div>
{:else if mode === "image" && sourceUrl}
<img data-testid="source-image" src={sourceUrl} alt={t("sourceCard.alt")} />
{:else if mode === "image"}
<span class="empty">{t("sourceCard.chooseImage")}</span>
{:else}
<span class="empty">{t("sourceCard.noSource")}</span>
{/if}
</PreviewTile>
<style>
.empty {
font: var(--font-size-s) var(--font-mono);
}
.text-source-wrap {
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { Copy, Download, FileText } from "@lucide/svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
interface Props {
value: string;
kind: "text" | "verdict";
toolId?: string;
vars?: Record<string, string | number>;
fileName?: string;
oncopy: () => void;
ondownload: () => void;
}
let {
value,
kind,
toolId = "",
vars = undefined,
fileName = "result.txt",
oncopy,
ondownload,
}: Props = $props();
const tone = $derived(verdictTone(value));
const displayValue = $derived(
kind === "verdict" && toolId ? verdictText(toolId, value, vars) : value,
);
</script>
{#if kind === "verdict"}
<div class="verdict">
<span class="verdict-label">{t("resultCard.result")}</span>
<div
data-testid="result-verdict"
class="badge badge-tone--{tone}"
role="status"
>
<span class="verdict-text">{displayValue}</span>
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
</div>
</div>
{:else}
<div class="text-result">
<div class="result-head">
<span class="result-label"><FileText size={14} /> {fileName}</span>
<div class="result-actions">
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
<IconButton
icon={Download}
label={t("textResult.downloadTxt")}
onclick={ondownload}
/>
</div>
</div>
<pre aria-label={t("textResult.outputAria")} class="result-pre"><code
>{value}</code
></pre>
</div>
{/if}
<style>
.text-result {
display: grid;
gap: var(--space-m);
min-width: 0;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
}
.result-label {
display: inline-flex;
align-items: center;
gap: var(--space-m);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
}
.result-actions {
display: flex;
gap: var(--space-s);
}
.result-pre {
margin: 0;
box-sizing: border-box;
max-height: var(--space-xxxl);
overflow: auto;
padding: var(--space-l) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
white-space: pre;
}
.verdict {
display: grid;
gap: var(--space-m);
justify-items: start;
}
.verdict-label {
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-l);
padding: var(--space-m) var(--space-xl);
border: var(--size-border-thick) solid transparent;
border-radius: var(--radius-m);
font: var(--font-size-m) var(--font-mono);
}
.badge-tone--success {
background: var(--color-success-tint);
border-color: var(--color-success);
color: var(--color-success);
}
.badge-tone--danger {
background: var(--color-danger-tint);
border-color: var(--color-danger);
color: var(--color-danger);
}
.badge-tone--info {
background: var(--color-info-tint);
border-color: var(--color-info);
color: var(--color-info);
}
.verdict-text {
line-height: normal;
}
</style>
@@ -0,0 +1,86 @@
<script lang="ts">
import { ArrowUp, Upload } from "@lucide/svelte";
import Button from "$lib/components/ui/Button.svelte";
import { t } from "$lib/i18n/t";
interface Props {
value: string;
placeholder?: string;
disabled?: boolean;
oninput: (text: string) => void;
onrender: () => void;
onsample?: () => void;
}
let {
value,
placeholder = t("textSource.placeholder"),
disabled = false,
oninput,
onrender,
onsample,
}: Props = $props();
</script>
<div class="text-source">
<textarea
aria-label={t("textInput.aria")}
spellcheck="false"
{placeholder}
rows="6"
{value}
oninput={(e) => oninput((e.target as HTMLTextAreaElement).value)}
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onrender();
}
}}></textarea>
<div class="actions">
{#if onsample}
<Button
icon={Upload}
label={t("textSource.trySample")}
variant="outline"
size="s"
onclick={onsample}
/>
{/if}
<Button
icon={ArrowUp}
label={t("textSource.render")}
size="s"
onclick={onrender}
{disabled}
/>
</div>
</div>
<style>
.text-source {
display: grid;
gap: var(--space-m);
}
textarea {
width: 100%;
min-height: var(--space-brand);
max-height: var(--space-xxxl);
resize: vertical;
box-sizing: border-box;
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
}
textarea:focus {
outline: none;
border-color: var(--color-main);
}
.actions {
display: flex;
gap: var(--space-m);
justify-content: flex-end;
}
</style>
@@ -0,0 +1,331 @@
<script lang="ts">
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors";
import { decodeFile, downloadBlob, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
import {
pageDescription,
pageTitle,
verdictText,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { FileResult, Page, Tool } from "$lib/registry";
import {
applySourceDefaults,
defaultSchemaParams,
sanitizeSchemaParams,
type ToolSchema,
} from "$lib/registry-schema";
import { downloadZip } from "$lib/zip";
interface Props {
page: Page;
tool: Tool;
}
type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
let { page, tool }: Props = $props();
const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
);
const inputMode = $derived(tool.input);
const resultKind = $derived(tool.result ?? "image");
let values = $state<Record<string, unknown>>({});
let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null);
let textSource = $state("");
let textResult = $state<string | null>(null);
let verdictVars = $state<Record<string, string | number> | undefined>(
undefined,
);
let running = $state(false);
let displayError = $state<DisplayError | null>(null);
let started = $state(false);
const errorText = $derived.by(() => {
const current = displayError;
if (!current) return "";
return current.kind === "i18n"
? t(current.key, current.vars)
: current.text;
});
const debouncedRun = debounce(() => run(), 200);
function init() {
if (!schema || started) return;
started = true;
values = defaultSchemaParams(schema);
}
function setValue(id: string, value: unknown) {
values = { ...values, [id]: value };
}
function reset() {
values = schema
? defaultSchemaParams(schema, { source: source ?? undefined })
: {};
result = null;
fileResult = null;
textResult = null;
verdictVars = undefined;
displayError = null;
}
async function handleFile(file: File) {
displayError = null;
try {
const decoded = await decodeFile(file);
source = decoded;
if (schema) {
values = applySourceDefaults(schema, values, { source: decoded });
}
} catch (e) {
displayError = toDisplayError(e);
}
}
async function run() {
if (!schema) return;
displayError = null;
running = true;
try {
const out = await execute(tool, {
params: sanitizeSchemaParams(schema, values, {
source: source ?? undefined,
}),
source: source ?? undefined,
text: textSource || undefined,
});
if (resultKind === "image") {
result = out as PixelImage;
textResult = null;
fileResult = null;
} else if (resultKind === "files") {
fileResult = out as FileResult;
result = null;
textResult = null;
} else {
if (typeof out === "string") {
textResult = out;
verdictVars = undefined;
} else if (out && "key" in out) {
textResult = out.key;
verdictVars = out.vars;
} else {
textResult = null;
verdictVars = undefined;
}
result = null;
fileResult = null;
}
} catch (e) {
displayError = toDisplayError(e);
} finally {
running = false;
}
}
async function download() {
if (!schema) return;
running = true;
displayError = null;
try {
if (resultKind === "files") {
if (!fileResult) return;
await downloadZip(fileResult.files, `${page.slug}.zip`);
return;
}
if (!result) return;
const out = tool.output;
const quality = out?.qualityParamId
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`);
} catch (e) {
displayError = toDisplayError(e);
} finally {
running = false;
}
}
async function copyText() {
if (!textResult) return;
const copyValue =
resultKind === "verdict"
? verdictText(tool.id, textResult, verdictVars)
: textResult;
try {
await navigator.clipboard.writeText(copyValue);
} catch (e) {
displayError = toDisplayError(e);
}
}
async function downloadText() {
if (!textResult) return;
try {
const blob = new Blob([textResult], { type: "text/plain" });
downloadBlob(blob, `${page.slug}.txt`);
} catch (e) {
displayError = toDisplayError(e);
}
}
$effect(() => {
init();
});
$effect(() => {
if (!started) return;
if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return;
void values;
void source;
debouncedRun();
return () => debouncedRun.cancel();
});
function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars };
}
if (e instanceof Error) return { kind: "plain", text: e.message };
return { kind: "plain", text: String(e) };
}
</script>
{#if schema}
<div class="schema-tool">
<header class="header">
<div class="title-block">
<h1>{pageTitle(page)}</h1>
<p class="lede">{pageDescription(page)}</p>
</div>
</header>
<div class="workspace">
<section class="panel settings">
<div class="panel-head">
<span class="label">{t("paramsCard.toolSettings")}</span>
<strong>{t("paramsCard.configureOutput")}</strong>
</div>
<SchemaFields
{schema}
{values}
toolId={tool.id}
onchange={setValue}
onreset={reset}
/>
</section>
<section class="panel">
<SchemaPreview
toolId={tool.id}
{source}
{result}
{fileResult}
{textSource}
{textResult}
textVars={verdictVars}
{inputMode}
{resultKind}
{running}
error={errorText}
onupload={handleFile}
ontextsource={(textValue) => {
textSource = textValue;
}}
onrendertext={run}
oncopytext={copyText}
ondownloadtxt={downloadText}
ondownload={download}
/>
</section>
</div>
</div>
{:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p>
{/if}
<style>
.schema-tool {
padding: calc(var(--space-xxxl) + var(--space-l))
clamp(var(--space-m), 4vw, var(--space-xxxl));
flex: 1;
}
.header {
display: flex;
justify-content: space-between;
gap: var(--space-l);
margin-bottom: var(--space-xxxl);
}
.title-block h1 {
margin: var(--space-s) 0 0;
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
line-height: 1.1;
color: var(--color-text);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-text-muted);
}
.label {
color: var(--color-main);
}
.lede {
max-width: 100%;
margin: var(--space-m) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-s);
line-height: 1.5;
}
.workspace {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax(
0,
2fr
);
gap: var(--space-xxl);
align-items: start;
}
.panel {
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
padding: var(--space-xl);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.panel-head strong {
font-size: var(--font-size-l);
color: var(--color-text);
}
.no-schema {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) {
.workspace {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "../../core/test-helpers";
import {
buildSchemaPreviewModel,
type PreviewTranslate,
} from "./schema-preview-model";
const image = makeImage(2, 3, [
[1, 2, 3, 255],
[4, 5, 6, 255],
[7, 8, 9, 255],
[10, 11, 12, 255],
[13, 14, 15, 255],
[16, 17, 18, 255],
]);
const translate: PreviewTranslate = (key) => {
if (key === "resultCard.parts") return "parts";
if (key === "textInput.heading") return "Text";
return key;
};
function input(
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
) {
return {
inputMode: "image" as const,
resultKind: "image" as const,
source: null,
result: null,
fileResult: null,
textResult: null,
...overrides,
};
}
describe("buildSchemaPreviewModel", () => {
it("formats source values for every input mode", () => {
expect(
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
).toMatchObject({ sourceValue: "—" });
expect(
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
).toMatchObject({ sourceValue: "Text" });
expect(
buildSchemaPreviewModel(
input({ inputMode: "image", source: image }),
translate,
),
).toMatchObject({ sourceValue: "2 × 3 px" });
});
it("formats image results and enables download only with a result", () => {
expect(
buildSchemaPreviewModel(input({ result: image }), translate),
).toEqual({
sourceValue: "—",
resultValue: "2 × 3 px",
formatValue: "PNG",
hasResult: true,
});
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
resultValue: "—",
hasResult: false,
});
});
it("formats file results as ZIP and rejects empty file results", () => {
const fileResult = { files: [{ name: "part.png", image }] };
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult }),
translate,
),
).toEqual({
sourceValue: "—",
resultValue: "1 parts",
formatValue: "ZIP (PNG)",
hasResult: true,
});
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult: { files: [] } }),
translate,
),
).toMatchObject({ resultValue: "—", hasResult: false });
});
it.each(["text", "verdict"] as const)(
"uses text result presence for %s output",
(resultKind) => {
expect(
buildSchemaPreviewModel(
input({ resultKind, textResult: "done" }),
translate,
),
).toMatchObject({
resultValue: "Text",
formatValue: "PNG",
hasResult: true,
});
expect(
buildSchemaPreviewModel(input({ resultKind }), translate),
).toMatchObject({
resultValue: "—",
hasResult: false,
});
},
);
});
@@ -0,0 +1,60 @@
import type { PixelImage } from "$lib/core/types";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
export type PreviewTranslate = (key: string) => string;
export type SchemaPreviewModelInput = {
inputMode: InputMode;
resultKind: ResultKind;
source: PixelImage | null;
result: PixelImage | null;
fileResult: FileResult | null;
textResult: string | null;
};
export type SchemaPreviewModel = {
sourceValue: string;
resultValue: string;
formatValue: string;
hasResult: boolean;
};
export function buildSchemaPreviewModel(
input: SchemaPreviewModelInput,
translate: PreviewTranslate,
): SchemaPreviewModel {
const { inputMode, resultKind, source, result, fileResult, textResult } =
input;
const sourceValue =
inputMode === "none"
? "—"
: inputMode === "text"
? translate("textInput.heading")
: source
? `${source.width} × ${source.height} px`
: "—";
const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
const resultValue =
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: resultKind === "files"
? hasFiles
? `${fileResult?.files.length} ${translate("resultCard.parts")}`
: "—"
: textResult
? translate("textInput.heading")
: "—";
return {
sourceValue,
resultValue,
formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
hasResult:
resultKind === "image"
? Boolean(result)
: resultKind === "files"
? hasFiles
: Boolean(textResult),
};
}