mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add tool search component, add chainable flag
This commit is contained in:
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isChainable, TOOLS } from '$lib/registry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: 'hero' | 'compact';
|
||||||
|
onSelect: (toolId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 'hero', onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const candidates = TOOLS.filter(isChainable);
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let activeIndex = $state(0);
|
||||||
|
let listOpen = $state(false);
|
||||||
|
|
||||||
|
type Match = { id: string; title: string; description: string; score: number };
|
||||||
|
|
||||||
|
function score(tool: (typeof TOOLS)[number], q: string): number | null {
|
||||||
|
if (q.length === 0) return 1;
|
||||||
|
const title = tool.title.toLowerCase();
|
||||||
|
const id = tool.id.toLowerCase();
|
||||||
|
const description = tool.description.toLowerCase();
|
||||||
|
if (title.startsWith(q)) return 100;
|
||||||
|
const inTitle = title.indexOf(q);
|
||||||
|
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
|
||||||
|
const inId = id.indexOf(q);
|
||||||
|
if (inId >= 0) return 60 - Math.min(inId, 40);
|
||||||
|
if (description.includes(q)) return 30;
|
||||||
|
let pos = 0;
|
||||||
|
let subScore = 20;
|
||||||
|
for (const ch of q) {
|
||||||
|
pos = title.indexOf(ch, pos);
|
||||||
|
if (pos < 0) {
|
||||||
|
subScore = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
subScore -= 1;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
return subScore >= 0 ? subScore : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const found: Match[] = [];
|
||||||
|
for (const tool of candidates) {
|
||||||
|
const s = score(tool, q);
|
||||||
|
if (s !== null && s > 0) {
|
||||||
|
found.push({ id: tool.id, title: tool.title, description: tool.description, score: s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||||
|
return found.slice(0, size === 'hero' ? 12 : 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(id: string) {
|
||||||
|
listOpen = false;
|
||||||
|
query = '';
|
||||||
|
activeIndex = 0;
|
||||||
|
onSelect(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (!listOpen || matches.length === 0) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = (activeIndex + 1) % matches.length;
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
||||||
|
if (match) choose(match.id);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
listOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="search {size}">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={query}
|
||||||
|
onfocus={() => (listOpen = true)}
|
||||||
|
oninput={() => {
|
||||||
|
listOpen = true;
|
||||||
|
activeIndex = 0;
|
||||||
|
}}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder="Найдите инструмент…"
|
||||||
|
aria-label="Поиск инструмента"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={listOpen}
|
||||||
|
aria-controls="tool-search-list"
|
||||||
|
/>
|
||||||
|
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||||
|
<p class="none text-muted">Ничего не найдено — попробуйте другое слово.</p>
|
||||||
|
{:else if listOpen && matches.length > 0}
|
||||||
|
<ul id="tool-search-list" role="listbox">
|
||||||
|
{#each matches as match, index (match.id)}
|
||||||
|
<li role="option" aria-selected={index === activeIndex}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:selected={index === activeIndex}
|
||||||
|
onclick={() => choose(match.id)}
|
||||||
|
onmousemove={() => (activeIndex = index)}
|
||||||
|
>
|
||||||
|
<span class="title">{match.title}</span>
|
||||||
|
<span class="hint text-caption text-muted">{match.description}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--control-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--text-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero input {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + var(--space-1));
|
||||||
|
z-index: 40;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-1);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button.selected,
|
||||||
|
li button:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-s);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -92,6 +92,10 @@ export type ToolEntry = {
|
|||||||
|
|
||||||
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||||
|
|
||||||
|
export function isChainable(tool: ToolEntry): boolean {
|
||||||
|
return (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file');
|
||||||
|
}
|
||||||
|
|
||||||
function num(params: Record<string, unknown>, id: string): number {
|
function num(params: Record<string, unknown>, id: string): number {
|
||||||
const v = params[id];
|
const v = params[id];
|
||||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<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 { CATEGORIES } from '$lib/categories';
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,11 +12,9 @@
|
|||||||
<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="Разделы">
|
||||||
<a class="nav-link workspace-link" href="/workspace">Рабочая область</a>
|
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
||||||
{#each CATEGORIES as category (category.id)}
|
<a class="nav-link" href="/list-tools">Каталог</a>
|
||||||
<a class="nav-link" href="/#{category.id}">{category.label}</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+40
-77
@@ -1,97 +1,60 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CATEGORIES } from '$lib/categories';
|
import { getTool } from '$lib/registry';
|
||||||
import { TOOLS } from '$lib/registry';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
|
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||||
|
|
||||||
|
let selectedId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
|
<title>
|
||||||
<meta
|
{selected ? `${selected.title} — easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
|
||||||
name="description"
|
</title>
|
||||||
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>easy-png-tools</h1>
|
{#if !selected}
|
||||||
<p class="lead text-muted">
|
<section class="hero">
|
||||||
Набор утилит для работы с PNG. Все операции выполняются локально в браузере — файлы никуда не
|
<h1>Что делаем с изображением?</h1>
|
||||||
отправляются.
|
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||||
</p>
|
<ToolSearch size="hero" onSelect={(id) => (selectedId = id)} />
|
||||||
|
</section>
|
||||||
{#each CATEGORIES as category (category.id)}
|
{:else}
|
||||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
<section class="workbench">
|
||||||
{#if categoryTools.length > 0}
|
<button type="button" class="back text-caption text-muted" onclick={() => (selectedId = null)}>
|
||||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
← Сменить инструмент
|
||||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
</button>
|
||||||
<div class="grid">
|
{#key selectedId}
|
||||||
{#each categoryTools as tool (tool.id)}
|
<ToolPage tool={selected} />
|
||||||
<a class="card panel" href="/tools/{tool.id}">
|
{/key}
|
||||||
<span class="card-title">{tool.title}</span>
|
</section>
|
||||||
<span class="card-desc text-caption text-muted">{tool.description}</span>
|
{/if}
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
max-width: 48rem;
|
max-width: 40rem;
|
||||||
margin-bottom: var(--space-5);
|
margin: 0 auto var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category {
|
.back {
|
||||||
margin-bottom: var(--space-5);
|
display: inline-block;
|
||||||
scroll-margin-top: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.back:hover {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3) var(--space-3) var(--space-4);
|
|
||||||
color: inherit;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
box-shadow var(--transition-fast),
|
|
||||||
transform var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .card-title {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CATEGORIES } from '$lib/categories';
|
||||||
|
import { TOOLS } from '$lib/registry';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Все инструменты — easy-png-tools</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Каталог инструментов</h1>
|
||||||
|
<p class="lead text-muted">
|
||||||
|
{TOOLS.length} утилит для работы с PNG. Все операции выполняются локально в браузере.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#each CATEGORIES as category (category.id)}
|
||||||
|
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||||
|
{#if categoryTools.length > 0}
|
||||||
|
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||||
|
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{#each categoryTools as tool (tool.id)}
|
||||||
|
<a class="card panel" href="/tools/{tool.id}">
|
||||||
|
<span class="card-title">{tool.title}</span>
|
||||||
|
<span class="card-desc text-caption text-muted">{tool.description}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-3) var(--space-4);
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user