mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add single tool pages, wiring with toolcard
This commit is contained in:
@@ -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
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user