feat: add svelte components

This commit is contained in:
2026-08-22 10:20:18 +05:00
parent 719bda2e22
commit c0225552e4
7 changed files with 552 additions and 0 deletions
+39
View File
@@ -82,3 +82,42 @@ select {
outline-offset: 2px;
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>
+107
View File
@@ -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>
+49
View File
@@ -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>
+89
View File
@@ -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>
+40
View File
@@ -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>
+187
View File
@@ -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>