mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
refactor: separate components
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user