feat: add single tool pages, wiring with toolcard

This commit is contained in:
2026-08-28 08:05:20 +05:00
parent f4216e6868
commit f990ad6767
7 changed files with 373 additions and 12 deletions
+1 -1
View File
@@ -330,7 +330,7 @@ src/routes/preview/tools/[id]/+page.svelte
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]` `CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
подключим на C12). подключим на C12).
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд. - [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
- [ ] **[C12]** `/preview/tools/[id]`раскладка gradient (settings-panel + preview-panel). - [x] **[C12]** `/preview/tools/[id]`data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
- [ ] **[C13]** `/preview/tools/[id]` — раскладка background-remover (comparison-grid). - [ ] **[C13]** `/preview/tools/[id]` — раскладка background-remover (comparison-grid).
- [ ] **[C14]** step-cards для chain в tool-page. - [ ] **[C14]** step-cards для chain в tool-page.
+18 -9
View File
@@ -10,6 +10,20 @@ import tseslint from "typescript-eslint";
// включается на весь код (см. план-redesign §10, шаг 6). // включается на весь код (см. план-redesign §10, шаг 6).
const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"]; const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"];
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
const jsRecommended = Array.isArray(js.configs.recommended)
? js.configs.recommended
: [js.configs.recommended];
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
? svelte.configs["flat/recommended"]
: [svelte.configs["flat/recommended"]];
// Svelte-рекомендации применяем только к .svelte-файлам нового кода, иначе
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts).
const svelteFiles = [
"**/src/lib/components/kit/**/*.svelte",
"**/src/routes/preview/**/*.svelte",
];
export default tseslint.config( export default tseslint.config(
{ {
ignores: [ ignores: [
@@ -68,15 +82,10 @@ export default tseslint.config(
}, },
// Полные recommended-наборы — только на новый код. // Полные recommended-наборы — только на новый код.
...[ ...[
...(Array.isArray(js.configs.recommended) ...jsRecommended.map((cfg) => ({ ...cfg, files: newCode })),
? js.configs.recommended ...tseslint.configs.recommended.map((cfg) => ({ ...cfg, files: newCode })),
: [js.configs.recommended]), ...svelteRecommended.map((cfg) => ({ ...cfg, files: svelteFiles })),
...tseslint.configs.recommended, ],
...svelte.configs["flat/recommended"],
].map((cfg) => ({
...cfg,
files: newCode,
})),
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и // В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный. // наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
@@ -0,0 +1,47 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
oninput?: (value: number) => void;
}
let { label, value = $bindable(), min, max, step, oninput }: Props = $props();
function handle(e: Event) {
value = Number((e.target as HTMLInputElement).value);
oninput?.(value);
}
</script>
<Field {label}>
<input
class="number-field"
type="number"
{min}
{max}
{step}
{value}
oninput={handle}
/>
</Field>
<style>
.number-field {
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.35rem 0.5rem;
}
.number-field:focus {
outline: none;
border-color: var(--blue);
}
</style>
+5 -1
View File
@@ -42,7 +42,11 @@
</div> </div>
<div class="ws-results"> <div class="ws-results">
{#each results as tool (tool.id)} {#each results as tool (tool.id)}
<ToolCard title={tool.title} href="#" description={tool.description} /> <ToolCard
title={tool.title}
href="/preview/tools/{tool.id}"
description={tool.description}
/>
{/each} {/each}
</div> </div>
<SettingsFooter> <SettingsFooter>
@@ -39,7 +39,7 @@
{#each group.tools as tool (tool.id)} {#each group.tools as tool (tool.id)}
<ToolCard <ToolCard
title={tool.title} title={tool.title}
href="#" href="/preview/tools/{tool.id}"
description={tool.description} description={tool.description}
/> />
{/each} {/each}
@@ -0,0 +1,290 @@
<script lang="ts">
import { browser } from "$app/environment";
import { untrack } from "svelte";
import { getTool, defaultParams } from "$lib/registry";
import { encode, downloadBlob, toDataUrl } from "$lib/core/io";
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import ColorField from "$lib/components/kit/ColorField.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import SelectField from "$lib/components/kit/SelectField.svelte";
import Toggle from "$lib/components/kit/Toggle.svelte";
import TextField from "$lib/components/kit/TextField.svelte";
import NumberField from "$lib/components/kit/NumberField.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
import Badge from "$lib/components/kit/Badge.svelte";
import StatusLine from "$lib/components/kit/StatusLine.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
type ParamValue = string | number | boolean;
interface Props {
data: { id: string };
}
let { data }: Props = $props();
const initialTool = untrack(() => getTool(data.id));
let params = $state<Record<string, ParamValue>>(
initialTool
? (defaultParams(initialTool) as Record<string, ParamValue>)
: {},
);
let previewUrl = $state("");
let previewError = $state("");
const tool = $derived(getTool(data.id));
$effect(() => {
const next = getTool(data.id);
if (next) params = defaultParams(next) as Record<string, ParamValue>;
});
$effect(() => {
if (!tool || !tool.generate) {
previewUrl = "";
return;
}
if (!browser) return;
const snapshot = { ...params };
try {
const result = tool.generate(snapshot);
if (result instanceof Promise) {
result
.then((img) => {
previewUrl = toDataUrl(img);
previewError = "";
})
.catch((e) => {
previewError = String(e);
});
} else {
previewUrl = toDataUrl(result);
previewError = "";
}
} catch (e) {
previewError = String(e);
}
});
async function download() {
if (!tool?.generate) return;
const result = tool.generate({ ...params });
const img = result instanceof Promise ? await result : result;
const blob = await encode(img, "image/png");
downloadBlob(blob, `${tool.id}.png`);
}
function setParam(id: string, value: ParamValue) {
params[id] = value;
}
const meta = $derived(
tool
? [
{ caption: "TOOL", value: tool.id },
{ caption: "CATEGORY", value: tool.category.toUpperCase() },
...(tool.sourceMode === "none" &&
"width" in params &&
"height" in params
? [
{
caption: "OUTPUT",
value: `${params.width} × ${params.height} px`,
},
]
: []),
]
: [],
);
</script>
<svelte:head>
<title>easy-png-tools / {tool?.title ?? "Tool"}</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{#if !tool}
<div class="tool-grid">
<EmptyState
title="Tool not found"
description="No tool is registered under “{data.id}”."
icon={ToolIcon}
/>
</div>
{:else}
<div class="tool-grid">
<section class="settings-panel">
<Panel>
<PanelHeading title={tool.title} eyebrow={tool.category}>
{#snippet actions()}
<Badge tone="accent">AUTO</Badge>
{/snippet}
</PanelHeading>
<p class="tool-desc">{tool.description}</p>
<div class="settings-body">
{#each tool.params as p (p.id)}
<div class="setting-row">
{#if p.type === "color"}
<ColorField
label={p.label}
value={params[p.id] as string}
oninput={(v) => setParam(p.id, v)}
/>
{:else if p.type === "slider"}
<SliderField
label={p.label}
value={params[p.id] as number}
min={p.min}
max={p.max}
step={p.step}
oninput={(v) => setParam(p.id, v)}
/>
{:else if p.type === "select"}
<SelectField
label={p.label}
value={params[p.id] as string}
options={p.options}
onchange={(v) => setParam(p.id, v)}
/>
{:else if p.type === "checkbox"}
<label class="toggle-row"
><span>{p.label}</span>
<Toggle
checked={params[p.id] as boolean}
onchange={(v) => setParam(p.id, v)}
/></label
>
{:else if p.type === "text"}
<TextField
label={p.label}
value={params[p.id] as string}
placeholder={p.placeholder}
oninput={(v) => setParam(p.id, v)}
/>
{:else if p.type === "number"}
<NumberField
label={p.label}
value={params[p.id] as number}
min={p.min}
max={p.max}
step={p.step}
oninput={(v) => setParam(p.id, v)}
/>
{/if}
</div>
{/each}
</div>
<SettingsFooter>
<StatusLine label="changes applied automatically" />
</SettingsFooter>
</Panel>
</section>
<section class="preview-panel">
<Panel>
<PanelHeading title="Preview" eyebrow="OUTPUT">
{#snippet actions()}
<DownloadButton label="Download PNG" onclick={download} />
{/snippet}
</PanelHeading>
<div class="preview-body">
{#if previewError}
<EmptyState title="Preview failed" description={previewError} />
{:else if previewUrl}
<PreviewTile label="RESULT">
<CheckerCanvas size="large">
<img class="result-img" src={previewUrl} alt="result" />
</CheckerCanvas>
</PreviewTile>
{:else if tool.generate}
<div class="preview-loading">
<StatusLine label="generating preview" />
</div>
{:else}
<EmptyState
title="No live preview"
description="This tool runs on an uploaded image. Source upload arrives in a later step."
icon={ToolIcon}
/>
{/if}
<MetaList items={meta} />
</div>
</Panel>
</section>
</div>
{/if}
<style>
.tool-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 40px;
max-width: 1680px;
margin: 0 auto;
padding: 48px clamp(24px, 4vw, 72px) 72px;
}
.tool-desc {
margin: 0;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 0.85rem;
line-height: 1.55;
}
.settings-body {
display: flex;
flex-direction: column;
}
.setting-row {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--foreground);
font-size: 0.85rem;
cursor: pointer;
}
.preview-panel {
position: sticky;
top: 84px;
}
.preview-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--muted);
}
.result-img {
max-width: 100%;
max-height: 360px;
display: block;
border-radius: var(--radius);
}
@media (max-width: 900px) {
.tool-grid {
grid-template-columns: 1fr;
gap: 24px;
padding: 32px 16px 48px;
}
.preview-panel {
position: static;
}
}
</style>
@@ -0,0 +1,11 @@
import { TOOLS } from "$lib/registry";
export const prerender = true;
export function entries() {
return TOOLS.map((tool) => ({ id: tool.id }));
}
export function load({ params }: { params: { id: string } }) {
return { id: params.id };
}