feat: add favorites tools

This commit is contained in:
2026-10-07 16:53:48 +05:00
parent 676be383d9
commit 2f11cb86ad
10 changed files with 208 additions and 12 deletions
+6 -5
View File
@@ -139,6 +139,9 @@
удобнее чем sectioned buttons
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
мгновенно пересортировываются и под мышкой оказывается другой элемент.
Нужна или анимация, или механизм отложенного обновления позиций
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
черная\белая\цветные\серая для проверки прозрачных картинок
@@ -175,11 +178,6 @@
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
страницы инструмента; список slug в localStorage. Избранное показывается
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
popularity при скоринге).
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
@@ -240,6 +238,9 @@
## Платформа (API/PWA)
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
популярности инструментов через Vercel KV (serverless-функция: INCR при
открытии инструмента, чтение топа для скоринга поиска).
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7)
+31 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { ArrowUpRight } from "@lucide/svelte";
import { ArrowUpRight, Star } from "@lucide/svelte";
import type { Component } from "svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { t } from "$lib/i18n/t";
interface Props {
title: string;
@@ -20,6 +22,20 @@
<strong>{title}</strong>
{#if description}<span>{description}</span>{/if}
</span>
<button
type="button"
class="tool-fav"
class:active={isFav(slug)}
aria-label={isFav(slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(slug)}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFav(slug);
}}
>
<Star size={15} fill={isFav(slug) ? "currentColor" : "none"} />
</button>
<span class="tool-index">
{index !== undefined ? index.toString().padStart(2, "0") : ""}
</span>
@@ -31,7 +47,7 @@
display: grid;
grid-template-columns:
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
var(--size-tool-arrow);
var(--space-xxl) var(--size-tool-arrow);
align-items: center;
gap: var(--space-l);
min-height: var(--size-tool-min-height);
@@ -54,6 +70,19 @@
color: var(--color-main);
border-radius: var(--radius-m);
}
.tool-fav {
display: grid;
place-items: center;
padding: var(--space-s);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.tool-fav:hover,
.tool-fav.active {
color: var(--color-warning);
}
.tool-copy {
display: flex;
flex-direction: column;
@@ -1,6 +1,8 @@
<script lang="ts">
import StepCard from "$lib/components/display/StepCard.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte";
import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors";
@@ -480,7 +482,19 @@
<div class="schema-tool">
<header class="header">
<div class="title-block">
<h1>{pageTitle(page)}</h1>
<h1>
{pageTitle(page)}
<button
type="button"
class="tool-fav"
class:active={isFav(page.slug)}
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)}
>
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} />
</button>
</h1>
<p class="lede">{pageDescription(page)}</p>
</div>
</header>
@@ -701,11 +715,27 @@
margin-bottom: var(--space-xxxl);
}
.title-block h1 {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin: var(--space-s) 0 0;
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
line-height: 1.1;
color: var(--color-text);
}
.tool-fav {
display: inline-grid;
place-items: center;
padding: var(--space-s);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.tool-fav:hover,
.tool-fav.active {
color: var(--color-warning);
}
.lede {
max-width: 100%;
margin: var(--space-m) 0 0;
+47
View File
@@ -0,0 +1,47 @@
import { SvelteSet } from "svelte/reactivity";
const KEY = "fav-tools";
const slugs = new SvelteSet<string>();
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function initFavorites(): void {
const raw = storage()?.getItem(KEY);
if (!raw) return;
try {
const list: unknown = JSON.parse(raw);
if (Array.isArray(list)) {
for (const s of list) if (typeof s === "string") slugs.add(s);
}
} catch {
// мусор в localStorage — работаем с пустым набором
}
}
export function isFav(slug: string): boolean {
return slugs.has(slug);
}
export function toggleFav(slug: string): void {
if (slugs.has(slug)) {
slugs.delete(slug);
} else {
slugs.add(slug);
}
storage()?.setItem(KEY, JSON.stringify([...slugs]));
}
/** Избранные страницы из списка (порядок сохраняется). */
export function favPages<T extends { slug: string }>(pages: readonly T[]): T[] {
return pages.filter((p) => slugs.has(p.slug));
}
/** Те же страницы, но избранные первыми (бонус в выдаче поиска). */
export function favFirst<T extends { slug: string }>(pages: readonly T[]): T[] {
return [...pages].sort(
(a, b) => Number(slugs.has(b.slug)) - Number(slugs.has(a.slug)),
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
favFirst,
favPages,
initFavorites,
isFav,
toggleFav,
} from "./favorites.svelte";
describe("favorites", () => {
it("toggle добавляет и убирает, isFav отражает состояние", () => {
expect(isFav("flip-png")).toBe(false);
toggleFav("flip-png");
expect(isFav("flip-png")).toBe(true);
toggleFav("flip-png");
expect(isFav("flip-png")).toBe(false);
});
it("favPages фильтрует, favFirst поднимает избранное", () => {
toggleFav("resize-png");
const pages = [{ slug: "crop-png" }, { slug: "resize-png" }];
expect(favPages(pages)).toEqual([{ slug: "resize-png" }]);
expect(favFirst(pages).map((p) => p.slug)).toEqual([
"resize-png",
"crop-png",
]);
toggleFav("resize-png");
});
it("initFavorites без localStorage и с мусором не падает", () => {
expect(() => initFavorites()).not.toThrow();
});
});
+3
View File
@@ -36,6 +36,7 @@ export const en: Dict = {
lead: "Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.",
toolsAvailable: "Tools available",
toolsCount: "Tools",
favorites: "Favorites",
},
toolPage: {
fallbackTitle: "Tool",
@@ -167,6 +168,8 @@ export const en: Dict = {
removeColor: "Remove color",
themeToggle: "Toggle theme",
language: "Language",
addFav: "Add to favorites",
removeFav: "Remove from favorites",
},
errors: {
noImageRun: "This tool does not process images",
+3
View File
@@ -37,6 +37,7 @@ export const ru: Dict = {
lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.",
toolsAvailable: "Инструментов доступно",
toolsCount: "Инструментов",
favorites: "Избранное",
},
toolPage: {
fallbackTitle: "Инструмент",
@@ -168,6 +169,8 @@ export const ru: Dict = {
removeColor: "Удалить цвет",
themeToggle: "Переключить тему",
language: "Язык",
addFav: "В избранное",
removeFav: "Убрать из избранного",
},
errors: {
noImageRun: "Этот инструмент не обрабатывает изображения",
+2
View File
@@ -3,6 +3,7 @@
import { page } from "$app/state";
import Footer from "$lib/components/layout/Footer.svelte";
import TopBar from "$lib/components/layout/TopBar.svelte";
import { initFavorites } from "$lib/favorites.svelte";
import { initLocale } from "$lib/i18n/locale.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
@@ -27,6 +28,7 @@
onMount(() => {
initLocale();
initTheme();
initFavorites();
});
let crumb = $derived(toCrumb(page.url.pathname));
+25 -1
View File
@@ -4,24 +4,35 @@
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte";
import {
pageDescription,
pageTitle,
searchPages,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons";
let query = $state("");
let category = $state<string>("all");
const favorites = $derived(
searchPages(
PAGES.filter((p) => isFav(p.slug)),
query,
),
);
const groups = $derived(
PREVIEW_GROUPS.map((g) => ({
id: g.id,
label: t(`categories.${g.id}`),
pages: searchPages(g.pages, query).filter(
pages: favFirst(
searchPages(g.pages, query).filter(
() => category === "all" || category === g.id,
),
),
})).filter((g) => g.pages.length > 0),
);
</script>
@@ -34,6 +45,19 @@
<CatalogHeader total={PREVIEW_TOTAL} />
<CatalogToolbar bind:query bind:category />
<div class="catalog-groups">
{#if favorites.length > 0}
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
{#each favorites as page, i (page.slug)}
<ToolCard
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
{/if}
{#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)}
+25 -1
View File
@@ -4,24 +4,35 @@
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte";
import {
pageDescription,
pageTitle,
searchPages,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons";
let query = $state("");
let category = $state<string>("all");
const favorites = $derived(
searchPages(
PAGES.filter((p) => isFav(p.slug)),
query,
),
);
const groups = $derived(
PREVIEW_GROUPS.map((g) => ({
id: g.id,
label: t(`categories.${g.id}`),
pages: searchPages(g.pages, query).filter(
pages: favFirst(
searchPages(g.pages, query).filter(
() => category === "all" || category === g.id,
),
),
})).filter((g) => g.pages.length > 0),
);
</script>
@@ -35,6 +46,19 @@
<CatalogHeader total={PREVIEW_TOTAL} />
<CatalogToolbar bind:query bind:category />
<div class="catalog-groups">
{#if favorites.length > 0}
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
{#each favorites as page, i (page.slug)}
<ToolCard
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
{/if}
{#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)}