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
+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: "Этот инструмент не обрабатывает изображения",