mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add favorites tools
This commit is contained in:
+6
-5
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Этот инструмент не обрабатывает изображения",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user