Compare commits
3
Commits
d3aca194c7
...
2301b0e165
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2301b0e165 | ||
|
|
8255d33098 | ||
|
|
3c0ffa23bc |
@@ -1,6 +1,6 @@
|
|||||||
# План: третья волна EASY — трансформации, свет, SVG
|
# План: третья волна EASY — трансформации, свет, SVG
|
||||||
|
|
||||||
> Статус: план к выполнению.
|
> **СТАТУС: ВЫПОЛНЕН 24.08.2026.**
|
||||||
|
|
||||||
## 1. Что получается
|
## 1. Что получается
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# План: i18n — русский и английский
|
||||||
|
|
||||||
|
> Статус: план к выполнению.
|
||||||
|
|
||||||
|
## 1. Что получается
|
||||||
|
|
||||||
|
Сайт работает на двух языках: русский (базовый) и английский. Переключатель в шапке, выбор сохраняется в localStorage, тег `html lang` обновляется на клиенте. Переведено всё: каркас интерфейса, карточки инструментов, цепочки, поиск, тексты ошибок. Поиск находит инструменты по запросу на любом из двух языков независимо от активной локали.
|
||||||
|
|
||||||
|
Каталог и поведение не меняются: ни новых инструментов, ни изменений логики существующих.
|
||||||
|
|
||||||
|
## 2. Решения
|
||||||
|
|
||||||
|
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
||||||
|
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
||||||
|
- Строки реестра живут в словарях, а не инлайн: словарь содержит секцию tools, ключ — id инструмента, внутри title, description, labels параметров и label'ы опций select. Реестр хранит только схему параметров и логику run.
|
||||||
|
|
||||||
|
## 3. Структура i18n-модуля
|
||||||
|
|
||||||
|
- web/src/lib/i18n/locale.svelte.ts — состояние активной локали (runes), персист в localStorage (ключ locale, guard на SSR), обновление document.documentElement.lang.
|
||||||
|
- web/src/lib/i18n/t.ts — функция перевода с фолбэком на базовый язык и интерполяцией переменных вида {n}.
|
||||||
|
- web/src/lib/i18n/ru.ts и en.ts — словари одного типа Dict; тип экспортируется из ru.ts, en.ts обязан ему соответствовать — пропущенные ключи ловятся на компиляции.
|
||||||
|
- Секции словаря: header, home, catalog, toolPage, chain, sourceCard, resultCard, paramsCard, textInput, textResult, download, infoPanel, dropZone, search, ui (aria кнопок слайдера и пипетки), categories, errors, tools.
|
||||||
|
|
||||||
|
## 4. Этапы и проверки
|
||||||
|
|
||||||
|
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||||
|
|
||||||
|
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||||
|
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||||
|
- C. Контент реестра — 69 инструментов: title/description/labels/options переезжают в tools-секцию обоих словарей; компоненты получают строки через хелперы toolTitle/paramLabel. Тест целостности: у каждого инструмента в каждом словаре непустые title/description, полный набор param-ключей и отсутствие лишних ключей.
|
||||||
|
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
||||||
|
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||||
|
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
||||||
|
|
||||||
|
## 5. Смоук-чеклист
|
||||||
|
|
||||||
|
1. Переключение RU/EN в шапке мгновенно меняет язык без перезагрузки; после перезагрузки выбор сохранён.
|
||||||
|
2. Тег html lang соответствует активной локали.
|
||||||
|
3. Главная, каталог, страница инструмента, цепочка из трёх звеньев — везде переведено, без смеси языков.
|
||||||
|
4. Форма параметров: подписи полей и опции select переведены у произвольных пяти инструментов из разных категорий.
|
||||||
|
5. Битый вход (некорректный HEX в тонировании, нулевой размер) показывает ошибку на активном языке.
|
||||||
|
6. Поиск: «повер» находит повороты при русской локали; «rotate» находит их же; при английской локали наоборот. Диакритика и ё не мешают совпадению.
|
||||||
|
7. Числа в панели информации отформатированы по локали.
|
||||||
|
8. Скачивание, пипетка, маски, слайдеры работают одинаково на обеих локалях — функциональность не задета.
|
||||||
|
|
||||||
|
## 6. Критерии готовности
|
||||||
|
|
||||||
|
- Оба словаря типобезопасны и полны; тест целостности секции tools зелёный.
|
||||||
|
- Ни одного пользовательского русского строки вне словарей (кроме тестовых describe/it).
|
||||||
|
- test/check/build зелёные, смоук пройден.
|
||||||
|
|
||||||
|
## 7. Что сознательно не делаем
|
||||||
|
|
||||||
|
- Сегменты языка в URL, prerender обеих локалей, hreflang/canonical.
|
||||||
|
- Транслитерация поисковых запросов ru-en.
|
||||||
|
- Третьи и дальнейшие языки (структура позволяет, добавление — отдельная волна).
|
||||||
|
- Перевод описаний коммитов, README и документации docs/.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { downloadBlob, encode } from '$lib/core/io';
|
import { downloadBlob, encode } from '$lib/core/io';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import type { OutputFormat } from '$lib/registry';
|
import type { OutputFormat } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: PixelImage | null;
|
image: PixelImage | null;
|
||||||
@@ -38,8 +39,8 @@
|
|||||||
fullWidth
|
fullWidth
|
||||||
disabled={!image || !format}
|
disabled={!image || !format}
|
||||||
{busy}
|
{busy}
|
||||||
busyText="Готовим файл…"
|
busyText={t('download.busy')}
|
||||||
onclick={download}
|
onclick={download}
|
||||||
>
|
>
|
||||||
Скачать .{format?.ext ?? 'png'}
|
{t('download.file', { ext: format?.ext ?? 'png' })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
let {
|
let {
|
||||||
onFile,
|
onFile,
|
||||||
onError,
|
onError,
|
||||||
label = 'Отпустите файл, чтобы заменить изображение',
|
label = t('dropZone.overlayDefault'),
|
||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
@@ -7,8 +8,7 @@
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onFile, onError, label = 'Перетащите изображение сюда или нажмите, чтобы выбрать файл' }: Props =
|
let { onFile, onError, label = t('dropZone.pickDefault') }: Props = $props();
|
||||||
$props();
|
|
||||||
|
|
||||||
let input = $state<HTMLInputElement | undefined>();
|
let input = $state<HTMLInputElement | undefined>();
|
||||||
let depth = $state(0);
|
let depth = $state(0);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from '$lib/core/analyze';
|
||||||
|
import { LOCALE_TAGS } from '$lib/i18n/dict';
|
||||||
|
import { getLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: ImageInfo | null;
|
info: ImageInfo | null;
|
||||||
@@ -11,16 +14,16 @@
|
|||||||
{#if info}
|
{#if info}
|
||||||
<dl>
|
<dl>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>Размеры</dt>
|
<dt>{t('infoPanel.dimensions')}</dt>
|
||||||
<dd>{info.width} × {info.height} px</dd>
|
<dd>{info.width} × {info.height} px</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>Альфа-канал</dt>
|
<dt>{t('infoPanel.alpha')}</dt>
|
||||||
<dd>{info.hasAlpha ? 'есть — есть полупрозрачные пиксели' : 'нет'}</dd>
|
<dd>{info.hasAlpha ? t('infoPanel.alphaYes') : t('infoPanel.alphaNo')}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>Уникальных цветов (RGBA)</dt>
|
<dt>{t('infoPanel.colorCount')}</dt>
|
||||||
<dd>{info.colorCount.toLocaleString('ru-RU')}</dd>
|
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import SliderField from './ui/SliderField.svelte';
|
import SliderField from './ui/SliderField.svelte';
|
||||||
import TextField from './ui/TextField.svelte';
|
import TextField from './ui/TextField.svelte';
|
||||||
import type { ParamDef } from '$lib/registry';
|
import type { ParamDef } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: ParamDef[];
|
params: ParamDef[];
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
|
|
||||||
<div class="params-grid">
|
<div class="params-grid">
|
||||||
{#if hasMask}
|
{#if hasMask}
|
||||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
<CheckboxField id="show-mask" label={t('ui.showMask')} bind:checked={showMask} />
|
||||||
{/if}
|
{/if}
|
||||||
{#each params as param (param.id)}
|
{#each params as param (param.id)}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||||
import { executeStep } from '$lib/tools/executor';
|
import { executeStep } from '$lib/tools/executor';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import ParamsCard from './tool/ParamsCard.svelte';
|
import ParamsCard from './tool/ParamsCard.svelte';
|
||||||
import ResultCard from './tool/ResultCard.svelte';
|
import ResultCard from './tool/ResultCard.svelte';
|
||||||
import SourceCard from './tool/SourceCard.svelte';
|
import SourceCard from './tool/SourceCard.svelte';
|
||||||
@@ -175,8 +176,10 @@
|
|||||||
try {
|
try {
|
||||||
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const rawMsg = e instanceof Error ? e.message : String(e);
|
||||||
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
|
throw new Error(
|
||||||
|
t('toolPage.stepError', { n: i + 1, title: stepTool.title, msg: t(rawMsg) })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
collected.push(current);
|
collected.push(current);
|
||||||
@@ -213,7 +216,8 @@
|
|||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
status = source ? 'loaded' : 'idle';
|
status = source ? 'loaded' : 'idle';
|
||||||
errorText = e instanceof Error ? e.message : String(e);
|
const raw = e instanceof Error ? e.message : String(e);
|
||||||
|
errorText = t(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -261,7 +265,7 @@
|
|||||||
<div class="panel tool-block">
|
<div class="panel tool-block">
|
||||||
<div class="tool-stage" class:single={isSourceless}>
|
<div class="tool-stage" class:single={isSourceless}>
|
||||||
{#if !isSourceless}
|
{#if !isSourceless}
|
||||||
<span class="edge-legend source-legend" aria-hidden="true">Исходник</span>
|
<span class="edge-legend source-legend" aria-hidden="true">{t('toolPage.legendSource')}</span>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
{#if isTextSource && !source}
|
{#if isTextSource && !source}
|
||||||
<TextInputCard onSubmit={handleTextSubmit} />
|
<TextInputCard onSubmit={handleTextSubmit} />
|
||||||
@@ -278,7 +282,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="edge-legend result-legend" aria-hidden="true">
|
<span class="edge-legend result-legend" aria-hidden="true">
|
||||||
{isInfo ? 'Сводка' : 'Результат'}
|
{isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')}
|
||||||
</span>
|
</span>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -300,7 +304,7 @@
|
|||||||
|
|
||||||
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
||||||
<div class="params-sep">
|
<div class="params-sep">
|
||||||
<span class="edge-legend" aria-hidden="true">Параметры</span>
|
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard
|
<ParamsCard
|
||||||
params={tool.params}
|
params={tool.params}
|
||||||
@@ -317,17 +321,17 @@
|
|||||||
{#each chain as step, index (step.id)}
|
{#each chain as step, index (step.id)}
|
||||||
{#if step.toolId === ''}
|
{#if step.toolId === ''}
|
||||||
<div class="panel empty-slot">
|
<div class="panel empty-slot">
|
||||||
<header>
|
<header>
|
||||||
<h3 class="heading-section">Шаг {index + 1}</h3>
|
<h3 class="heading-section">{t('toolPage.stepHeading', { n: index + 1 })}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="remove-step"
|
class="remove-step"
|
||||||
aria-label="Убрать шаг"
|
aria-label={t('toolPage.removeStepAria')}
|
||||||
onclick={() => removeChainStep(index)}
|
onclick={() => removeChainStep(index)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
||||||
</div>
|
</div>
|
||||||
{:else if getTool(step.toolId)}
|
{:else if getTool(step.toolId)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from '../ui/Button.svelte';
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
@@ -47,12 +48,12 @@
|
|||||||
{#if StepIcon}
|
{#if StepIcon}
|
||||||
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
Шаг {index + 1}: {tool.title}
|
{t('chain.stepLabel', { n: index + 1, title: tool.title })}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="remove"
|
class="remove"
|
||||||
aria-label="Убрать шаг"
|
aria-label={t('chain.removeStepAria')}
|
||||||
title="Убрать шаг"
|
title={t('chain.removeStepAria')}
|
||||||
onclick={onRemove}
|
onclick={onRemove}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -60,22 +61,22 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true">Вход</span>
|
<span class="edge-legend cell-legend" aria-hidden="true">{t('chain.inputLegend')}</span>
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<Preview image={input} />
|
<Preview image={input} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true">Результат</span>
|
<span class="edge-legend cell-legend" aria-hidden="true">{t('chain.resultLegend')}</span>
|
||||||
{#if busy && !result}
|
{#if busy && !result}
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
|
<EmptyState title={t('chain.busyTitle')} hint={t('chain.busyHint')} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<Preview image={result} />
|
<Preview image={result} />
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
{onError}
|
{onError}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
||||||
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
|
{isLast ? t('resultCard.nextTool') : t('resultCard.breakChain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
|
|
||||||
{#if tool.params.length > 0}
|
{#if tool.params.length > 0}
|
||||||
<div class="params-sep">
|
<div class="params-sep">
|
||||||
<span class="edge-legend" aria-hidden="true">Параметры</span>
|
<span class="edge-legend" aria-hidden="true">{t('chain.paramsLegend')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard params={tool.params} bind:values />
|
<ParamsCard params={tool.params} bind:values />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isChainable, TOOLS } from '$lib/registry';
|
import { isChainable, TOOLS } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import ToolCard from './ToolCard.svelte';
|
import ToolCard from './ToolCard.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -106,14 +107,14 @@
|
|||||||
activeIndex = 0;
|
activeIndex = 0;
|
||||||
}}
|
}}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
placeholder="Найдите инструмент…"
|
placeholder={t('search.placeholder')}
|
||||||
aria-label="Поиск инструмента"
|
aria-label={t('search.aria')}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={listOpen}
|
aria-expanded={listOpen}
|
||||||
aria-controls="tool-search-list"
|
aria-controls="tool-search-list"
|
||||||
/>
|
/>
|
||||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||||
<p class="none text-muted">Ничего не найдено — попробуйте другое слово.</p>
|
<p class="none text-muted">{t('search.nothingFound')}</p>
|
||||||
{:else if listOpen && matches.length > 0}
|
{:else if listOpen && matches.length > 0}
|
||||||
<div id="tool-search-list" class="cards" role="listbox">
|
<div id="tool-search-list" class="cards" role="listbox">
|
||||||
{#each matches as match, index (match.id)}
|
{#each matches as match, index (match.id)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ParamForm from '../ParamForm.svelte';
|
import ParamForm from '../ParamForm.svelte';
|
||||||
import type { ParamDef } from '$lib/registry';
|
import type { ParamDef } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: ParamDef[];
|
params: ParamDef[];
|
||||||
@@ -32,9 +33,7 @@
|
|||||||
bind:showMask
|
bind:showMask
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hint text-caption text-muted">
|
<p class="hint text-caption text-muted">{t('paramsCard.noParams')}</p>
|
||||||
У этого инструмента нет параметров — результат уже готов.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from '$lib/core/analyze';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import { outputOf, type ToolEntry } from '$lib/registry';
|
import { outputOf, type ToolEntry } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||||
|
|
||||||
@@ -45,16 +46,13 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
{#if !sourceLoaded}
|
{#if !sourceLoaded}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<EmptyState
|
<EmptyState title={t('resultCard.emptyTitle')} hint={t('resultCard.emptyHint')} />
|
||||||
title="Результат появится здесь"
|
|
||||||
hint="Сначала загрузите исходное изображение слева"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if !isInfo && status === 'processing' && !result}
|
{:else if !isInfo && status === 'processing' && !result}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Обработка…"
|
title={t('resultCard.processingTitle')}
|
||||||
hint="Изображение обрабатывается, это займёт немного времени"
|
hint={t('resultCard.processingHint')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if isInfo}
|
{:else if isInfo}
|
||||||
@@ -73,7 +71,7 @@
|
|||||||
<div class="media">
|
<div class="media">
|
||||||
<Preview image={displayImage} />
|
<Preview image={displayImage} />
|
||||||
{#if status === 'processing'}
|
{#if status === 'processing'}
|
||||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
@@ -86,7 +84,7 @@
|
|||||||
/>
|
/>
|
||||||
{#if onChainToggle}
|
{#if onChainToggle}
|
||||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||||
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
|
{hasChain ? t('resultCard.breakChain') : t('resultCard.nextTool')}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import DropOverlay from '../DropOverlay.svelte';
|
import DropOverlay from '../DropOverlay.svelte';
|
||||||
import DropZone from '../DropZone.svelte';
|
import DropZone from '../DropZone.svelte';
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from '../Preview.svelte';
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<div class="media">
|
<div class="media">
|
||||||
<Preview image={source} pipetteActive={pipetteActive} onPickColor={onPickColor} />
|
<Preview image={source} pipetteActive={pipetteActive} onPickColor={onPickColor} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onclick={onReset}>Заменить изображение</Button>
|
<Button variant="secondary" onclick={onReset}>{t('sourceCard.replaceImage')}</Button>
|
||||||
</DropOverlay>
|
</DropOverlay>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from '../ui/Button.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
@@ -16,16 +17,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="heading-section">Текст</h2>
|
<h2 class="heading-section">{t('textInput.heading')}</h2>
|
||||||
<textarea
|
<textarea
|
||||||
class="input"
|
class="input"
|
||||||
rows="8"
|
rows="8"
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
placeholder="Вставьте данные сюда"
|
placeholder={t('textInput.placeholder')}
|
||||||
aria-label="Текстовые данные"
|
aria-label={t('textInput.aria')}
|
||||||
></textarea>
|
></textarea>
|
||||||
<Button variant="secondary" onclick={submit} disabled={text.trim().length === 0}>
|
<Button variant="secondary" onclick={submit} disabled={text.trim().length === 0}>
|
||||||
Декодировать
|
{t('textInput.decode')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloadBlob } from '$lib/core/io';
|
import { downloadBlob } from '$lib/core/io';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -22,12 +23,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel text-result">
|
<div class="panel text-result">
|
||||||
<textarea class="output" rows="10" readonly value={text} aria-label="Текстовый результат"></textarea>
|
<textarea class="output" rows="10" readonly value={text} aria-label={t('textResult.outputAria')}></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="secondary" onclick={copy}>
|
<button type="button" class="secondary" onclick={copy}>
|
||||||
{copied ? 'Скопировано' : 'Копировать'}
|
{copied ? t('textResult.copied') : t('textResult.copy')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="primary" onclick={download}>Скачать .txt</button>
|
<button type="button" class="primary" onclick={download}>{t('textResult.downloadTxt')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,9 +22,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="pipette"
|
class="pipette"
|
||||||
aria-label="Пипетка"
|
aria-label={t('ui.pipette')}
|
||||||
aria-pressed={pipetteActive}
|
aria-pressed={pipetteActive}
|
||||||
title="Пипетка"
|
title={t('ui.pipette')}
|
||||||
onclick={onPipetteToggle}
|
onclick={onPipetteToggle}
|
||||||
>
|
>
|
||||||
◎
|
◎
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,13 +43,13 @@
|
|||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="step" aria-label="Уменьшить" onclick={decrement}>−</button>
|
<button type="button" class="step" aria-label={t('ui.decrease')} onclick={decrement}>−</button>
|
||||||
<input id={id} type="range" min={min} max={max} step={step} bind:value />
|
<input id={id} type="range" min={min} max={max} step={step} bind:value />
|
||||||
<button type="button" class="step" aria-label="Увеличить" onclick={increment}>+</button>
|
<button type="button" class="step" aria-label={t('ui.increase')} onclick={increment}>+</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="step"
|
class="step"
|
||||||
aria-label="Сбросить"
|
aria-label={t('ui.reset')}
|
||||||
disabled={resetDisabled}
|
disabled={resetDisabled}
|
||||||
onclick={reset}
|
onclick={reset}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { CategoryId } from '../categories';
|
||||||
|
|
||||||
|
export const LOCALES = ['ru', 'en'] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
export const BASE_LOCALE: Locale = 'ru';
|
||||||
|
|
||||||
|
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||||
|
ru: 'ru-RU',
|
||||||
|
en: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isLocale(value: unknown): value is Locale {
|
||||||
|
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolStrings = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Подписи параметров по их id. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
/** Подписи опций select: paramId -> value -> label. */
|
||||||
|
options?: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeaderStrings = {
|
||||||
|
workspace: string;
|
||||||
|
catalog: string;
|
||||||
|
sectionsAria: string;
|
||||||
|
footerNote: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Dict = {
|
||||||
|
header: HeaderStrings;
|
||||||
|
categories: Record<CategoryId, string>;
|
||||||
|
home: Record<string, string>;
|
||||||
|
catalog: Record<string, string>;
|
||||||
|
toolPage: Record<string, string>;
|
||||||
|
chain: Record<string, string>;
|
||||||
|
sourceCard: Record<string, string>;
|
||||||
|
resultCard: Record<string, string>;
|
||||||
|
paramsCard: Record<string, string>;
|
||||||
|
textInput: Record<string, string>;
|
||||||
|
textResult: Record<string, string>;
|
||||||
|
download: Record<string, string>;
|
||||||
|
infoPanel: Record<string, string>;
|
||||||
|
dropZone: Record<string, string>;
|
||||||
|
search: Record<string, string>;
|
||||||
|
ui: Record<string, string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
tools: Record<string, ToolStrings>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
|
export const en: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: 'Workspace',
|
||||||
|
catalog: 'Catalog',
|
||||||
|
sectionsAria: 'Sections',
|
||||||
|
footerNote:
|
||||||
|
'All operations run locally in your browser — your files are never uploaded anywhere.'
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
convert: 'Convert',
|
||||||
|
alpha: 'Transparency',
|
||||||
|
color: 'Color',
|
||||||
|
geometry: 'Geometry',
|
||||||
|
filters: 'Filters',
|
||||||
|
analyze: 'Analyze',
|
||||||
|
generate: 'Generate'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
defaultTitle: 'easy-png-tools — PNG utilities right in your browser',
|
||||||
|
heroTitle: 'What do you want to do with the image?',
|
||||||
|
heroLead: 'Find a tool — everything runs locally in your browser.',
|
||||||
|
restoreLast: '↩ Restore last: {title}',
|
||||||
|
changeTool: '← Change tool'
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
pageTitle: 'All tools — easy-png-tools',
|
||||||
|
metaDescription:
|
||||||
|
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
|
||||||
|
heading: 'Tool catalog',
|
||||||
|
lead: '{count} utilities for working with PNG. Everything runs locally in your browser.'
|
||||||
|
},
|
||||||
|
toolPage: {
|
||||||
|
fallbackTitle: 'Tool',
|
||||||
|
legendSource: 'Source',
|
||||||
|
legendSummary: 'Summary',
|
||||||
|
legendResult: 'Result',
|
||||||
|
legendParams: 'Parameters',
|
||||||
|
stepHeading: 'Step {n}',
|
||||||
|
removeStepAria: 'Remove step',
|
||||||
|
stepError: 'Step {n} ({title}): {msg}'
|
||||||
|
},
|
||||||
|
chain: {
|
||||||
|
stepLabel: 'Step {n}: {title}',
|
||||||
|
inputLegend: 'Input',
|
||||||
|
resultLegend: 'Result',
|
||||||
|
paramsLegend: 'Parameters',
|
||||||
|
busyTitle: 'Processing…',
|
||||||
|
busyHint: 'Running chain step',
|
||||||
|
removeStepAria: 'Remove step'
|
||||||
|
},
|
||||||
|
sourceCard: {
|
||||||
|
replaceImage: 'Replace image'
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
emptyTitle: 'The result will appear here',
|
||||||
|
emptyHint: 'First upload a source image on the left',
|
||||||
|
processingTitle: 'Processing…',
|
||||||
|
processingHint: 'The image is being processed, this will take a moment',
|
||||||
|
recalc: 'Recalculating…',
|
||||||
|
nextTool: '⛓ Next tool',
|
||||||
|
breakChain: '✂ Break the chain'
|
||||||
|
},
|
||||||
|
paramsCard: {
|
||||||
|
noParams: 'This tool has no parameters — the result is ready as is.'
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
heading: 'Text',
|
||||||
|
placeholder: 'Paste data here',
|
||||||
|
aria: 'Text data',
|
||||||
|
decode: 'Decode'
|
||||||
|
},
|
||||||
|
textResult: {
|
||||||
|
outputAria: 'Text result',
|
||||||
|
copied: 'Copied',
|
||||||
|
copy: 'Copy',
|
||||||
|
downloadTxt: 'Download .txt'
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
busy: 'Preparing file…',
|
||||||
|
file: 'Download .{ext}'
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
dimensions: 'Dimensions',
|
||||||
|
alpha: 'Alpha channel',
|
||||||
|
alphaYes: 'yes — there are semi-transparent pixels',
|
||||||
|
alphaNo: 'no',
|
||||||
|
colorCount: 'Unique colors (RGBA)'
|
||||||
|
},
|
||||||
|
dropZone: {
|
||||||
|
pickDefault: 'Drop an image here or click to choose a file',
|
||||||
|
overlayDefault: 'Release the file to replace the image'
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Find a tool…',
|
||||||
|
aria: 'Search tools',
|
||||||
|
nothingFound: 'Nothing found — try another word.'
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
showMask: 'Show mask',
|
||||||
|
decrease: 'Decrease',
|
||||||
|
increase: 'Increase',
|
||||||
|
reset: 'Reset',
|
||||||
|
pipette: 'Eyedropper'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noImageRun: 'This tool does not process images',
|
||||||
|
workerFailed: 'Worker execution failed',
|
||||||
|
workerUnavailable: 'Worker is unavailable',
|
||||||
|
notFound: 'Tool not found'
|
||||||
|
},
|
||||||
|
tools: {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
||||||
|
import { interpolate, t } from './t';
|
||||||
|
import { ru } from './ru';
|
||||||
|
|
||||||
|
type Store = Record<string, string>;
|
||||||
|
|
||||||
|
function stubStorage(): { store: Store } {
|
||||||
|
const store: Store = {};
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => (k in store ? store[k] : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
store[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
return { store };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setLocale('ru');
|
||||||
|
delete (ru.home as Record<string, string>).onlyRuKey;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('t', () => {
|
||||||
|
it('возвращает строку по точечному пути активной локали', () => {
|
||||||
|
expect(t('header.workspace')).toBe('Рабочая область');
|
||||||
|
setLocale('en');
|
||||||
|
expect(t('header.catalog')).toBe('Catalog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
|
||||||
|
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
|
||||||
|
setLocale('en');
|
||||||
|
expect(t('home.onlyRuKey')).toBe('Только русская строка');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестный путь возвращает сам путь', () => {
|
||||||
|
expect(t('no.such.key')).toBe('no.such.key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interpolate', () => {
|
||||||
|
it('подставляет переменные в шаблон', () => {
|
||||||
|
expect(interpolate('Шаг {n} из {total}', { n: 2, total: 5 })).toBe('Шаг 2 из 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оставляет плейсхолдер без переменной как есть', () => {
|
||||||
|
expect(interpolate('Привет, {name}!', {})).toBe('Привет, {name}!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без переменных возвращает строку без изменений', () => {
|
||||||
|
expect(interpolate('Просто текст')).toBe('Просто текст');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('персист локали', () => {
|
||||||
|
it('initLocale читает сохранённый выбор', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
store['locale'] = 'en';
|
||||||
|
initLocale();
|
||||||
|
expect(getLocale()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initLocale игнорирует мусор в хранилище', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
store['locale'] = 'fr';
|
||||||
|
initLocale();
|
||||||
|
expect(getLocale()).toBe('ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLocale сохраняет выбор в localStorage', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
initLocale();
|
||||||
|
setLocale('en');
|
||||||
|
expect(store['locale']).toBe('en');
|
||||||
|
expect(getLocale()).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { BASE_LOCALE, isLocale, type Dict, type Locale } from './dict';
|
||||||
|
import { ru } from './ru';
|
||||||
|
import { en } from './en';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'locale';
|
||||||
|
|
||||||
|
const DICTS: Record<Locale, Dict> = { ru, en };
|
||||||
|
|
||||||
|
let locale = $state<Locale>(BASE_LOCALE);
|
||||||
|
|
||||||
|
function storage(): Storage | null {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale(): Locale {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDict(): Dict {
|
||||||
|
return DICTS[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
|
||||||
|
export function getMergedDict(): Dict {
|
||||||
|
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
|
||||||
|
const active = DICTS[locale];
|
||||||
|
const base = DICTS[BASE_LOCALE];
|
||||||
|
return new Proxy(base, {
|
||||||
|
get(_target, section: string) {
|
||||||
|
const a = (active as unknown as Record<string, unknown>)[section];
|
||||||
|
const b = (base as unknown as Record<string, unknown>)[section];
|
||||||
|
if (
|
||||||
|
a && b && typeof a === 'object' && typeof b === 'object'
|
||||||
|
) {
|
||||||
|
return { ...(b as object), ...(a as object) };
|
||||||
|
}
|
||||||
|
return a ?? b;
|
||||||
|
}
|
||||||
|
}) as Dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(next: Locale): void {
|
||||||
|
locale = next;
|
||||||
|
storage()?.setItem(STORAGE_KEY, next);
|
||||||
|
syncLangAttr();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
|
||||||
|
export function initLocale(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const saved = storage()?.getItem(STORAGE_KEY);
|
||||||
|
if (isLocale(saved)) locale = saved;
|
||||||
|
syncLangAttr();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLangAttr(): void {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
|
export const ru: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: 'Рабочая область',
|
||||||
|
catalog: 'Каталог',
|
||||||
|
sectionsAria: 'Разделы',
|
||||||
|
footerNote:
|
||||||
|
'Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.'
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
convert: 'Конвертация',
|
||||||
|
alpha: 'Прозрачность',
|
||||||
|
color: 'Цвет',
|
||||||
|
geometry: 'Геометрия',
|
||||||
|
filters: 'Фильтры',
|
||||||
|
analyze: 'Анализ',
|
||||||
|
generate: 'Генерация'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
defaultTitle: 'easy-png-tools — PNG-утилиты прямо в браузере',
|
||||||
|
heroTitle: 'Что делаем с изображением?',
|
||||||
|
heroLead: 'Найдите инструмент — все операции выполняются локально в браузере.',
|
||||||
|
restoreLast: '↩ Вернуть последний: {title}',
|
||||||
|
changeTool: '← Сменить инструмент'
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
pageTitle: 'Все инструменты — easy-png-tools',
|
||||||
|
metaDescription:
|
||||||
|
'Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений.',
|
||||||
|
heading: 'Каталог инструментов',
|
||||||
|
lead: '{count} утилит для работы с PNG. Все операции выполняются локально в браузере.'
|
||||||
|
},
|
||||||
|
toolPage: {
|
||||||
|
fallbackTitle: 'Инструмент',
|
||||||
|
legendSource: 'Исходник',
|
||||||
|
legendSummary: 'Сводка',
|
||||||
|
legendResult: 'Результат',
|
||||||
|
legendParams: 'Параметры',
|
||||||
|
stepHeading: 'Шаг {n}',
|
||||||
|
removeStepAria: 'Убрать шаг',
|
||||||
|
stepError: 'Шаг {n} ({title}): {msg}'
|
||||||
|
},
|
||||||
|
chain: {
|
||||||
|
stepLabel: 'Шаг {n}: {title}',
|
||||||
|
inputLegend: 'Вход',
|
||||||
|
resultLegend: 'Результат',
|
||||||
|
paramsLegend: 'Параметры',
|
||||||
|
busyTitle: 'Обработка…',
|
||||||
|
busyHint: 'Выполняется шаг цепочки',
|
||||||
|
removeStepAria: 'Убрать шаг'
|
||||||
|
},
|
||||||
|
sourceCard: {
|
||||||
|
replaceImage: 'Заменить изображение'
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
emptyTitle: 'Результат появится здесь',
|
||||||
|
emptyHint: 'Сначала загрузите исходное изображение слева',
|
||||||
|
processingTitle: 'Обработка…',
|
||||||
|
processingHint: 'Изображение обрабатывается, это займёт немного времени',
|
||||||
|
recalc: 'Пересчёт…',
|
||||||
|
nextTool: '⛓ Следующий инструмент',
|
||||||
|
breakChain: '✂ Оборвать цепочку'
|
||||||
|
},
|
||||||
|
paramsCard: {
|
||||||
|
noParams: 'У этого инструмента нет параметров — результат уже готов.'
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
heading: 'Текст',
|
||||||
|
placeholder: 'Вставьте данные сюда',
|
||||||
|
aria: 'Текстовые данные',
|
||||||
|
decode: 'Декодировать'
|
||||||
|
},
|
||||||
|
textResult: {
|
||||||
|
outputAria: 'Текстовый результат',
|
||||||
|
copied: 'Скопировано',
|
||||||
|
copy: 'Копировать',
|
||||||
|
downloadTxt: 'Скачать .txt'
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
busy: 'Готовим файл…',
|
||||||
|
file: 'Скачать .{ext}'
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
dimensions: 'Размеры',
|
||||||
|
alpha: 'Альфа-канал',
|
||||||
|
alphaYes: 'есть — есть полупрозрачные пиксели',
|
||||||
|
alphaNo: 'нет',
|
||||||
|
colorCount: 'Уникальных цветов (RGBA)'
|
||||||
|
},
|
||||||
|
dropZone: {
|
||||||
|
pickDefault: 'Перетащите изображение сюда или нажмите, чтобы выбрать файл',
|
||||||
|
overlayDefault: 'Отпустите файл, чтобы заменить изображение'
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Найдите инструмент…',
|
||||||
|
aria: 'Поиск инструмента',
|
||||||
|
nothingFound: 'Ничего не найдено — попробуйте другое слово.'
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
showMask: 'Показать маску',
|
||||||
|
decrease: 'Уменьшить',
|
||||||
|
increase: 'Увеличить',
|
||||||
|
reset: 'Сбросить',
|
||||||
|
pipette: 'Пипетка'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
|
workerFailed: 'Ошибка исполнения в воркере',
|
||||||
|
workerUnavailable: 'Воркер недоступен',
|
||||||
|
notFound: 'Инструмент не найден'
|
||||||
|
},
|
||||||
|
tools: {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getMergedDict } from './locale.svelte';
|
||||||
|
|
||||||
|
export function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||||
|
name in vars ? String(vars[name]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
|
||||||
|
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
|
||||||
|
*/
|
||||||
|
export function t(path: string, vars?: Record<string, string | number>): string {
|
||||||
|
let node: unknown = getMergedDict();
|
||||||
|
for (const part of path.split('.')) {
|
||||||
|
if (node && typeof node === 'object' && part in (node as Record<string, unknown>)) {
|
||||||
|
node = (node as Record<string, unknown>)[part];
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof node === 'string' ? interpolate(node, vars) : path;
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ describe('executeStep: прямой путь (среда без Worker)', () =>
|
|||||||
|
|
||||||
it('инструмент без run даёт понятную ошибку', async () => {
|
it('инструмент без run даёт понятную ошибку', async () => {
|
||||||
await expect(executeStep({ id: 'stub' }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
|
await expect(executeStep({ id: 'stub' }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
|
||||||
'не обрабатывает изображения'
|
'errors.noImageRun'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function executeStep(
|
|||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<PixelImage> {
|
): Promise<PixelImage> {
|
||||||
if (!tool.run) {
|
if (!tool.run) {
|
||||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
throw new Error('errors.noImageRun');
|
||||||
}
|
}
|
||||||
if (typeof Worker === 'undefined') {
|
if (typeof Worker === 'undefined') {
|
||||||
return await runDirect(tool, img, params);
|
return await runDirect(tool, img, params);
|
||||||
@@ -35,7 +35,7 @@ async function runDirect(
|
|||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<PixelImage> {
|
): Promise<PixelImage> {
|
||||||
if (!tool.run) {
|
if (!tool.run) {
|
||||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
throw new Error('errors.noImageRun');
|
||||||
}
|
}
|
||||||
return await tool.run(img, params);
|
return await tool.run(img, params);
|
||||||
}
|
}
|
||||||
@@ -74,13 +74,13 @@ function ensureWorker(): Worker | null {
|
|||||||
data: new Uint8ClampedArray(payload.data)
|
data: new Uint8ClampedArray(payload.data)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(payload.error ?? 'Ошибка исполнения в воркере'));
|
entry.reject(new Error(payload.error ?? 'errors.workerFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
candidate.onerror = () => {
|
candidate.onerror = () => {
|
||||||
worker = null;
|
worker = null;
|
||||||
for (const entry of pending.values()) {
|
for (const entry of pending.values()) {
|
||||||
entry.reject(new Error('Воркер недоступен'));
|
entry.reject(new Error('errors.workerUnavailable'));
|
||||||
}
|
}
|
||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const tool = getTool(request.toolId);
|
const tool = getTool(request.toolId);
|
||||||
if (!tool?.run) {
|
if (!tool?.run) {
|
||||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
throw new Error('errors.noImageRun');
|
||||||
}
|
}
|
||||||
const image: PixelImage = {
|
const image: PixelImage = {
|
||||||
width: request.image.width,
|
width: request.image.width,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { LOCALES, type Locale } from '$lib/i18n/dict';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => initLocale());
|
||||||
|
|
||||||
|
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,9 +20,22 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">easy-png-tools</a>
|
<a href="/" class="brand">easy-png-tools</a>
|
||||||
<nav aria-label="Разделы">
|
<nav aria-label={t('header.sectionsAria')}>
|
||||||
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
<a class="nav-link workspace-link" href="/">{t('header.workspace')}</a>
|
||||||
<a class="nav-link" href="/list-tools">Каталог</a>
|
<a class="nav-link" href="/list-tools">{t('header.catalog')}</a>
|
||||||
|
<div class="lang-switch" role="group" aria-label="Language / Язык">
|
||||||
|
{#each LOCALES as l (l)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lang-btn"
|
||||||
|
class:active={getLocale() === l}
|
||||||
|
aria-pressed={getLocale() === l}
|
||||||
|
onclick={() => setLocale(l)}
|
||||||
|
>
|
||||||
|
{LANG_LABELS[l]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -23,9 +44,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p class="text-caption text-muted">
|
<p class="text-caption text-muted">{t('header.footerNote')}</p>
|
||||||
Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.
|
|
||||||
</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,6 +96,36 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-s);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: calc(var(--radius-s) - 1px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-link {
|
.workspace-link {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getTool } from '$lib/registry';
|
import { getTool } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
@@ -42,20 +43,20 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{selected ? `${selected.title} — easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
|
{selected ? `${selected.title} — easy-png-tools` : t('home.defaultTitle')}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !selected}
|
{#if !selected}
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Что делаем с изображением?</h1>
|
<h1>{t('home.heroTitle')}</h1>
|
||||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
<p class="lead text-muted">{t('home.heroLead')}</p>
|
||||||
<ToolSearch onSelect={(id) => openTool(id)} />
|
<ToolSearch onSelect={(id) => openTool(id)} />
|
||||||
{#if lastToolId !== null && getTool(lastToolId)}
|
{#if lastToolId !== null && getTool(lastToolId)}
|
||||||
{@const restoreId = lastToolId}
|
{@const restoreId = lastToolId}
|
||||||
<div class="restore-row">
|
<div class="restore-row">
|
||||||
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
||||||
↩ Вернуть последний: {getTool(restoreId)?.title}
|
{t('home.restoreLast', { title: getTool(restoreId)?.title ?? '' })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
<button type="button" class="back text-caption text-muted" onclick={closeTool}>
|
<button type="button" class="back text-caption text-muted" onclick={closeTool}>
|
||||||
← Сменить инструмент
|
{t('home.changeTool')}
|
||||||
</button>
|
</button>
|
||||||
{#key selectedId}
|
{#key selectedId}
|
||||||
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CATEGORIES } from '$lib/categories';
|
import { CATEGORIES } from '$lib/categories';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import ToolCard from '$lib/components/search/ToolCard.svelte';
|
import ToolCard from '$lib/components/search/ToolCard.svelte';
|
||||||
import { TOOLS } from '$lib/registry';
|
import { TOOLS } from '$lib/registry';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Все инструменты — easy-png-tools</title>
|
<title>{t('catalog.pageTitle')}</title>
|
||||||
<meta
|
<meta name="description" content={t('catalog.metaDescription')} />
|
||||||
name="description"
|
|
||||||
content="Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений."
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Каталог инструментов</h1>
|
<h1>{t('catalog.heading')}</h1>
|
||||||
<p class="lead text-muted">
|
<p class="lead text-muted">{t('catalog.lead', { count: TOOLS.length })}</p>
|
||||||
{TOOLS.length} утилит для работы с PNG. Все операции выполняются локально в браузере.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#each CATEGORIES as category (category.id)}
|
{#each CATEGORIES as category (category.id)}
|
||||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||||
{#if categoryTools.length > 0}
|
{#if categoryTools.length > 0}
|
||||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
<h2 id="{category.id}-heading" class="heading-section">{t(`categories.${category.id}`)}</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each categoryTools as tool (tool.id)}
|
{#each categoryTools as tool (tool.id)}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
import { getTool } from '$lib/registry';
|
import { getTool } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{tool?.title ?? 'Инструмент'} — easy-png-tools</title>
|
<title>{tool?.title ?? t('toolPage.fallbackTitle')} — easy-png-tools</title>
|
||||||
{#if tool}
|
{#if tool}
|
||||||
<meta name="description" content={tool.description} />
|
<meta name="description" content={tool.description} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getTool, TOOLS } from '$lib/registry';
|
import { getTool, TOOLS } from '$lib/registry';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
import type { EntryGenerator, PageLoad } from './$types';
|
import type { EntryGenerator, PageLoad } from './$types';
|
||||||
|
|
||||||
export const entries: EntryGenerator = () => TOOLS.map((tool) => ({ id: tool.id }));
|
export const entries: EntryGenerator = () => TOOLS.map((tool) => ({ id: tool.id }));
|
||||||
@@ -7,7 +8,7 @@ export const entries: EntryGenerator = () => TOOLS.map((tool) => ({ id: tool.id
|
|||||||
export const load: PageLoad = ({ params }) => {
|
export const load: PageLoad = ({ params }) => {
|
||||||
const tool = getTool(params.id);
|
const tool = getTool(params.id);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
error(404, 'Инструмент не найден');
|
error(404, t('errors.notFound'));
|
||||||
}
|
}
|
||||||
return { id: tool.id };
|
return { id: tool.id };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user