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:
@@ -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: "Этот инструмент не обрабатывает изображения",
|
||||
|
||||
Reference in New Issue
Block a user