feat: add svelte components
This commit is contained in:
@@ -82,3 +82,42 @@ select {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.primary,
|
||||||
|
button.secondary {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 120ms ease,
|
||||||
|
border-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { downloadBlob, encode } from '$lib/core/io';
|
||||||
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import type { OutputFormat } from '$lib/registry';
|
||||||
|
|
||||||
|
let {
|
||||||
|
image,
|
||||||
|
format,
|
||||||
|
baseName,
|
||||||
|
params,
|
||||||
|
onError
|
||||||
|
}: {
|
||||||
|
image: PixelImage | null;
|
||||||
|
format?: OutputFormat;
|
||||||
|
baseName: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
onError?: (e: unknown) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (!image || !format || busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const raw = format.qualityParamId ? params[format.qualityParamId] : undefined;
|
||||||
|
const quality =
|
||||||
|
typeof raw === 'number' ? Math.min(Math.max(raw, 1), 100) / 100 : undefined;
|
||||||
|
const blob = await encode(image, format.mime, quality);
|
||||||
|
downloadBlob(blob, `${baseName}.${format.ext}`);
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="primary" onclick={download} disabled={!image || !format || busy}>
|
||||||
|
{busy ? 'Готовим файл…' : `Скачать .${format?.ext ?? 'png'}`}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ACCEPTED_IMAGE_TYPES } from '$lib/core/io';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onFile,
|
||||||
|
onError,
|
||||||
|
label = 'Перетащите изображение сюда или нажмите, чтобы выбрать файл'
|
||||||
|
}: {
|
||||||
|
onFile: (file: File) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
label?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let input = $state<HTMLInputElement | undefined>();
|
||||||
|
let dragging = $state(false);
|
||||||
|
|
||||||
|
const acceptedMimes = new Set(ACCEPTED_IMAGE_TYPES.split(','));
|
||||||
|
|
||||||
|
function accept(file: File | undefined | null) {
|
||||||
|
if (!file) return;
|
||||||
|
if (!acceptedMimes.has(file.type)) {
|
||||||
|
onError?.(
|
||||||
|
`Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
input?.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="dropzone"
|
||||||
|
class:dragging
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={label}
|
||||||
|
onclick={openPicker}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openPicker();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
accept(e.dataTransfer?.files[0]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="icon">⬇</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_IMAGE_TYPES}
|
||||||
|
hidden
|
||||||
|
onchange={() => {
|
||||||
|
accept(input?.files?.[0]);
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 14rem;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition:
|
||||||
|
border-color 120ms ease,
|
||||||
|
background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone:hover,
|
||||||
|
.dropzone:focus-visible,
|
||||||
|
.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ImageInfo } from '$lib/core/analyze';
|
||||||
|
|
||||||
|
let { info }: { info: ImageInfo | null } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if info}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Размеры</dt>
|
||||||
|
<dd>{info.width} × {info.height} px</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Альфа-канал</dt>
|
||||||
|
<dd>{info.hasAlpha ? 'есть — есть полупрозрачные пиксели' : 'нет'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Уникальных цветов (RGBA)</dt>
|
||||||
|
<dd>{info.colorCount.toLocaleString('ru-RU')}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ParamDef } from '$lib/registry';
|
||||||
|
|
||||||
|
let {
|
||||||
|
params,
|
||||||
|
values = $bindable()
|
||||||
|
}: {
|
||||||
|
params: ParamDef[];
|
||||||
|
values: Record<string, any>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each params as param (param.id)}
|
||||||
|
<div class="field">
|
||||||
|
{#if param.type === 'checkbox'}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" bind:checked={values[param.id]} />
|
||||||
|
{param.label}
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<label for={param.id}>{param.label}</label>
|
||||||
|
{#if param.type === 'number'}
|
||||||
|
<input
|
||||||
|
id={param.id}
|
||||||
|
type="number"
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step}
|
||||||
|
bind:value={values[param.id]}
|
||||||
|
/>
|
||||||
|
{:else if param.type === 'select'}
|
||||||
|
<select id={param.id} bind:value={values[param.id]}>
|
||||||
|
{#each param.options as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if param.type === 'color'}
|
||||||
|
<input id={param.id} type="color" bind:value={values[param.id]} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
label,
|
||||||
|
.checkbox {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--surface);
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number'] {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
height: 2.4rem;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
|
||||||
|
let { image }: { image: PixelImage | null } = $props();
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!canvas || !image) return;
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if image}
|
||||||
|
<canvas bind:this={canvas} title="{image.width} × {image.height}"></canvas>
|
||||||
|
<p class="dims">{image.width} × {image.height} px</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 24rem;
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dims {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||||
|
import { decodeFile } from '$lib/core/io';
|
||||||
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import { defaultParams, outputOf, type ToolEntry } from '$lib/registry';
|
||||||
|
import DownloadButton from './DownloadButton.svelte';
|
||||||
|
import DropZone from './DropZone.svelte';
|
||||||
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
|
import ParamForm from './ParamForm.svelte';
|
||||||
|
import Preview from './Preview.svelte';
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolEntry } = $props();
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||||
|
|
||||||
|
let status = $state<Status>('idle');
|
||||||
|
let source = $state<PixelImage | null>(null);
|
||||||
|
let result = $state<PixelImage | null>(null);
|
||||||
|
let info = $state<ImageInfo | null>(null);
|
||||||
|
let errorText = $state('');
|
||||||
|
let values = $state<Record<string, any>>({});
|
||||||
|
|
||||||
|
const isInfo = $derived(tool.resultType === 'info');
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
errorText = '';
|
||||||
|
status = 'processing';
|
||||||
|
try {
|
||||||
|
source = await decodeFile(file);
|
||||||
|
values = defaultParams(tool);
|
||||||
|
result = null;
|
||||||
|
info = isInfo ? imageInfo(source) : null;
|
||||||
|
if (isInfo) {
|
||||||
|
status = 'loaded';
|
||||||
|
} else {
|
||||||
|
await apply();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (!source || isInfo || status === 'processing') return;
|
||||||
|
errorText = '';
|
||||||
|
status = 'processing';
|
||||||
|
try {
|
||||||
|
result = await tool.run(source, values);
|
||||||
|
status = 'loaded';
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(e: unknown) {
|
||||||
|
status = source ? 'loaded' : 'idle';
|
||||||
|
errorText = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
source = null;
|
||||||
|
result = null;
|
||||||
|
info = null;
|
||||||
|
errorText = '';
|
||||||
|
status = 'idle';
|
||||||
|
values = defaultParams(tool);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>{tool.title}</h1>
|
||||||
|
<p class="description">{tool.description}</p>
|
||||||
|
|
||||||
|
{#if errorText}
|
||||||
|
<div class="error" role="alert">{errorText}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !source}
|
||||||
|
<DropZone onFile={handleFile} onError={(message) => (errorText = message)} />
|
||||||
|
{:else}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="result-col">
|
||||||
|
<h2>{isInfo ? 'Изображение' : 'Результат'}</h2>
|
||||||
|
{#if status === 'processing' && !result}
|
||||||
|
<p class="hint">Обработка…</p>
|
||||||
|
{:else}
|
||||||
|
<Preview image={isInfo ? source : result} />
|
||||||
|
{/if}
|
||||||
|
{#if isInfo && info}
|
||||||
|
<InfoPanel {info} />
|
||||||
|
{/if}
|
||||||
|
<button class="secondary" onclick={reset}>Загрузить другое изображение</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isInfo}
|
||||||
|
<div class="params-col">
|
||||||
|
<h2>Параметры</h2>
|
||||||
|
{#if tool.params.length > 0}
|
||||||
|
<ParamForm params={tool.params} bind:values />
|
||||||
|
<button class="primary" onclick={apply} disabled={status === 'processing'}>
|
||||||
|
{status === 'processing' ? 'Обработка…' : 'Применить'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">У этого инструмента нет параметров — результат уже готов.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="download-row">
|
||||||
|
<DownloadButton
|
||||||
|
image={result}
|
||||||
|
format={outputOf(tool)}
|
||||||
|
baseName={tool.id}
|
||||||
|
params={values}
|
||||||
|
onError={showError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 48rem;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
border: 1px solid #e5484d;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: color-mix(in srgb, #e5484d 8%, var(--surface));
|
||||||
|
color: #b3261e;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-col h2,
|
||||||
|
.params-col h2 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-col button.secondary {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-col button.primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-row {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-row :global(button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user