feat: add favorites functionality to tools

This commit is contained in:
alam00000
2026-10-22 15:04:42 +05:30
parent 4d3e8b964f
commit 7eec9a0d64
29 changed files with 795 additions and 47 deletions
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "المفضلة",
"popularTools": "الأدوات الشائعة",
"editAnnotate": "تعديل وتعليق",
"convertToPdf": "تحويل إلى PDF",
@@ -10,6 +11,13 @@
"input": "إدخال",
"output": "إخراج"
},
"favorites": {
"add": "إضافة إلى المفضلة",
"remove": "إزالة من المفضلة",
"hintLabel": "تلميح",
"hint": "اضغط مطولًا على أي أداة لإضافتها إلى المفضلة.",
"hideHint": "إخفاء التلميح"
},
"convertToPdf": "تحويل إلى PDF",
"pdfMultiTool": {
"name": "أداة PDF المتعددة",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Абранае",
"popularTools": "Папулярныя інструменты",
"editAnnotate": "Рэдагаванне і анатацыі",
"convertToPdf": "Канвертацыя ў PDF",
@@ -10,6 +11,13 @@
"input": "Увод",
"output": "Вывад"
},
"favorites": {
"add": "Дадаць у абранае",
"remove": "Выдаліць з абранага",
"hintLabel": "Парада",
"hint": "Націсніце і ўтрымлівайце інструмент, каб дадаць яго ў абранае.",
"hideHint": "Схаваць параду"
},
"convertToPdf": "Канвертаваць у PDF",
"pdfMultiTool": {
"name": "Мультыінструмент PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Preferits",
"popularTools": "Eines populars",
"editAnnotate": "Edita i anota",
"convertToPdf": "Converteix a PDF",
@@ -10,6 +11,13 @@
"input": "Entrada",
"output": "Sortida"
},
"favorites": {
"add": "Afegeix als preferits",
"remove": "Treu dels preferits",
"hintLabel": "Consell",
"hint": "Mantén premuda una eina per afegir-la als preferits.",
"hideHint": "Amaga el consell"
},
"convertToPdf": "Converteix a PDF",
"pdfMultiTool": {
"name": "Eina PDF multifunció",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoritter",
"popularTools": "Populære værktøjer",
"editAnnotate": "Rediger og annotér",
"convertToPdf": "Konverter til PDF",
@@ -10,6 +11,13 @@
"input": "Input",
"output": "Output"
},
"favorites": {
"add": "Føj til favoritter",
"remove": "Fjern fra favoritter",
"hintLabel": "Tip",
"hint": "Tryk og hold på et værktøj for at føje det til dine favoritter.",
"hideHint": "Skjul tip"
},
"convertToPdf": "Konvertér til PDF",
"pdfMultiTool": {
"name": "PDF Multi-værktøj",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoriten",
"popularTools": "Beliebte Werkzeuge",
"editAnnotate": "Bearbeiten & Annotieren",
"convertToPdf": "In PDF konvertieren",
@@ -10,6 +11,13 @@
"input": "Eingabe",
"output": "Ausgabe"
},
"favorites": {
"add": "Zu Favoriten hinzufügen",
"remove": "Aus Favoriten entfernen",
"hintLabel": "Tipp",
"hint": "Halte ein Werkzeug gedrückt, um es zu deinen Favoriten hinzuzufügen.",
"hideHint": "Tipp ausblenden"
},
"convertToPdf": "In PDF konvertieren",
"pdfMultiTool": {
"name": "PDF Multi-Werkzeug",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favorites",
"popularTools": "Popular Tools",
"editAnnotate": "Edit & Annotate",
"convertToPdf": "Convert to PDF",
@@ -10,6 +11,13 @@
"input": "Input",
"output": "Output"
},
"favorites": {
"add": "Add to favorites",
"remove": "Remove from favorites",
"hintLabel": "Tip",
"hint": "Press and hold a tool to add it to your favorites.",
"hideHint": "Hide tip"
},
"convertToPdf": "Convert to PDF",
"pdfMultiTool": {
"name": "PDF Multi Tool",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoritos",
"popularTools": "Herramientas Populares",
"editAnnotate": "Editar y Anotar",
"convertToPdf": "Convertir a PDF",
@@ -10,6 +11,13 @@
"input": "Entrada",
"output": "Salida"
},
"favorites": {
"add": "Añadir a favoritos",
"remove": "Quitar de favoritos",
"hintLabel": "Consejo",
"hint": "Mantén pulsada una herramienta para añadirla a tus favoritos.",
"hideHint": "Ocultar consejo"
},
"convertToPdf": "Convertir a PDF",
"pdfMultiTool": {
"name": "Multiherramienta PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "علاقه‌مندی‌ها",
"popularTools": "ابزارهای پرکاربرد",
"editAnnotate": "ویرایش و حاشیه‌نویسی",
"convertToPdf": "تبدیل به PDF",
@@ -10,6 +11,13 @@
"input": "ورودی",
"output": "خروجی"
},
"favorites": {
"add": "افزودن به علاقه‌مندی‌ها",
"remove": "حذف از علاقه‌مندی‌ها",
"hintLabel": "نکته",
"hint": "برای افزودن یک ابزار به علاقه‌مندی‌ها، آن را لمس کرده و نگه دارید.",
"hideHint": "پنهان کردن نکته"
},
"convertToPdf": "تبدیل به PDF",
"pdfMultiTool": {
"name": "ابزار چندکارهٔ PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoris",
"popularTools": "Outils populaires",
"editAnnotate": "Éditer et annoter",
"convertToPdf": "Convertir en PDF",
@@ -10,6 +11,13 @@
"input": "Entrée",
"output": "Sortie"
},
"favorites": {
"add": "Ajouter aux favoris",
"remove": "Retirer des favoris",
"hintLabel": "Astuce",
"hint": "Appuyez longuement sur un outil pour l’ajouter à vos favoris.",
"hideHint": "Masquer l’astuce"
},
"convertToPdf": "Convertir en PDF",
"pdfMultiTool": {
"name": "Outil PDF tout-en-un",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favorit",
"popularTools": "Alat Populer",
"editAnnotate": "Edit & Anotasi",
"convertToPdf": "Konversi ke PDF",
@@ -10,6 +11,13 @@
"input": "Masukan",
"output": "Keluaran"
},
"favorites": {
"add": "Tambahkan ke favorit",
"remove": "Hapus dari favorit",
"hintLabel": "Tips",
"hint": "Tekan lama sebuah alat untuk menambahkannya ke favorit.",
"hideHint": "Sembunyikan tips"
},
"convertToPdf": "Konversi ke PDF",
"pdfMultiTool": {
"name": "Alat Multi PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Preferiti",
"popularTools": "Strumenti popolari",
"editAnnotate": "Modifica e Annota",
"convertToPdf": "Converti in PDF",
@@ -10,6 +11,13 @@
"input": "Input",
"output": "Output"
},
"favorites": {
"add": "Aggiungi ai preferiti",
"remove": "Rimuovi dai preferiti",
"hintLabel": "Suggerimento",
"hint": "Tieni premuto uno strumento per aggiungerlo ai preferiti.",
"hideHint": "Nascondi suggerimento"
},
"convertToPdf": "Converti in PDF",
"pdfMultiTool": {
"name": "Strumento PDF multifunzione",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "お気に入り",
"popularTools": "人気のツール",
"editAnnotate": "編集と注釈",
"convertToPdf": "PDFに変換",
@@ -10,6 +11,13 @@
"input": "入力",
"output": "出力"
},
"favorites": {
"add": "お気に入りに追加",
"remove": "お気に入りから削除",
"hintLabel": "ヒント",
"hint": "ツールを長押しするとお気に入りに追加できます。",
"hideHint": "ヒントを非表示"
},
"convertToPdf": "PDFに変換",
"pdfMultiTool": {
"name": "PDFマルチツール",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "즐겨찾기",
"popularTools": "인기 도구",
"editAnnotate": "편집 및 주석",
"convertToPdf": "PDF로 변환",
@@ -10,6 +11,13 @@
"input": "입력",
"output": "출력"
},
"favorites": {
"add": "즐겨찾기에 추가",
"remove": "즐겨찾기에서 제거",
"hintLabel": "팁",
"hint": "도구를 길게 누르면 즐겨찾기에 추가할 수 있습니다.",
"hideHint": "팁 숨기기"
},
"convertToPdf": "PDF로 변환",
"pdfMultiTool": {
"name": "PDF 멀티 도구",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favorieten",
"popularTools": "Populaire Tools",
"editAnnotate": "Bewerken & Annoteren",
"convertToPdf": "Converteren naar PDF",
@@ -10,6 +11,13 @@
"input": "Invoer",
"output": "Uitvoer"
},
"favorites": {
"add": "Toevoegen aan favorieten",
"remove": "Verwijderen uit favorieten",
"hintLabel": "Tip",
"hint": "Houd een tool ingedrukt om hem aan je favorieten toe te voegen.",
"hideHint": "Tip verbergen"
},
"convertToPdf": "Naar PDF converteren",
"pdfMultiTool": {
"name": "PDF Multi-tool",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Ulubione",
"popularTools": "Popularne narzędzia",
"editAnnotate": "Edytuj i adnotuj",
"convertToPdf": "Konwertuj na PDF",
@@ -10,6 +11,13 @@
"input": "Wejście",
"output": "Wyjście"
},
"favorites": {
"add": "Dodaj do ulubionych",
"remove": "Usuń z ulubionych",
"hintLabel": "Wskazówka",
"hint": "Naciśnij i przytrzymaj narzędzie, aby dodać je do ulubionych.",
"hideHint": "Ukryj wskazówkę"
},
"convertToPdf": "Konwertuj na PDF",
"pdfMultiTool": {
"name": "Wielofunkcyjne narzędzie PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoritos",
"popularTools": "Ferramentas Populares",
"editAnnotate": "Editar e Anotar",
"convertToPdf": "Converter para PDF",
@@ -10,6 +11,13 @@
"input": "Entrada",
"output": "Saída"
},
"favorites": {
"add": "Adicionar aos favoritos",
"remove": "Remover dos favoritos",
"hintLabel": "Dica",
"hint": "Toque e segure uma ferramenta para adicioná-la aos favoritos.",
"hideHint": "Ocultar dica"
},
"convertToPdf": "Converter para PDF",
"pdfMultiTool": {
"name": "Multiferramenta PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Избранное",
"popularTools": "Популярные инструменты",
"editAnnotate": "Редактирование и добавление примечаний",
"convertToPdf": "Конвертация в PDF",
@@ -10,6 +11,13 @@
"input": "Ввод",
"output": "Вывод"
},
"favorites": {
"add": "Добавить в избранное",
"remove": "Удалить из избранного",
"hintLabel": "Совет",
"hint": "Нажмите и удерживайте инструмент, чтобы добавить его в избранное.",
"hideHint": "Скрыть совет"
},
"convertToPdf": "Конвертировать в PDF",
"pdfMultiTool": {
"name": "PDF мультиинструмент",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Obľúbené",
"popularTools": "Populárne nástroje",
"editAnnotate": "Úprava a anotácia",
"convertToPdf": "Previesť do PDF",
@@ -10,6 +11,13 @@
"input": "Vstup",
"output": "Výstup"
},
"favorites": {
"add": "Pridať do obľúbených",
"remove": "Odstrániť z obľúbených",
"hintLabel": "Tip",
"hint": "Podržte prst na nástroji a pridajte ho do obľúbených.",
"hideHint": "Skryť tip"
},
"convertToPdf": "Previesť do PDF",
"pdfMultiTool": {
"name": "PDF Multi Nástroj",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoriter",
"popularTools": "Populära verktyg",
"editAnnotate": "Redigera & Kommentera",
"convertToPdf": "Konvertera till PDF",
@@ -10,6 +11,13 @@
"input": "Inmatning",
"output": "Utdata"
},
"favorites": {
"add": "Lägg till i favoriter",
"remove": "Ta bort från favoriter",
"hintLabel": "Tips",
"hint": "Tryck och håll på ett verktyg för att lägga till det i dina favoriter.",
"hideHint": "Dölj tips"
},
"convertToPdf": "Konvertera till PDF",
"pdfMultiTool": {
"name": "PDF multiverktyg",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Favoriler",
"popularTools": "Popüler Araçlar",
"editAnnotate": "Düzenle & Açıklama Ekle",
"convertToPdf": "PDF'ye Dönüştür",
@@ -10,6 +11,13 @@
"input": "Giriş",
"output": "Çıkış"
},
"favorites": {
"add": "Favorilere ekle",
"remove": "Favorilerden kaldır",
"hintLabel": "İpucu",
"hint": "Bir aracı favorilerinize eklemek için basılı tutun.",
"hideHint": "İpucunu gizle"
},
"convertToPdf": "PDF'ye Dönüştür",
"pdfMultiTool": {
"name": "PDF Çoklu Araç",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Вибране",
"popularTools": "Популярні інструменти",
"editAnnotate": "Редагування та анотації",
"convertToPdf": "Конвертація у PDF",
@@ -10,6 +11,13 @@
"input": "Ввід",
"output": "Вивід"
},
"favorites": {
"add": "Додати до вибраного",
"remove": "Видалити з вибраного",
"hintLabel": "Порада",
"hint": "Натисніть і утримуйте інструмент, щоб додати його до вибраного.",
"hideHint": "Сховати пораду"
},
"convertToPdf": "Конвертувати в PDF",
"pdfMultiTool": {
"name": "PDF мультиінструмент",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "Yêu thích",
"popularTools": "Công cụ phổ biến",
"editAnnotate": "Chỉnh sửa & Ghi chú",
"convertToPdf": "Chuyển đổi sang PDF",
@@ -10,6 +11,13 @@
"input": "Đầu vào",
"output": "Đầu ra"
},
"favorites": {
"add": "Thêm vào yêu thích",
"remove": "Xóa khỏi yêu thích",
"hintLabel": "Mẹo",
"hint": "Nhấn giữ một công cụ để thêm vào mục yêu thích.",
"hideHint": "Ẩn mẹo"
},
"convertToPdf": "Chuyển đổi sang PDF",
"pdfMultiTool": {
"name": "Công cụ đa năng PDF",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "我的最愛",
"popularTools": "熱門工具",
"editAnnotate": "編輯與註解",
"convertToPdf": "轉換為 PDF",
@@ -10,6 +11,13 @@
"input": "輸入",
"output": "輸出"
},
"favorites": {
"add": "加入我的最愛",
"remove": "從我的最愛移除",
"hintLabel": "提示",
"hint": "長按工具即可加入我的最愛。",
"hideHint": "隱藏提示"
},
"convertToPdf": "轉換為 PDF",
"pdfMultiTool": {
"name": "PDF 多功能工具",
+8
View File
@@ -1,5 +1,6 @@
{
"categories": {
"favorites": "收藏",
"popularTools": "热门工具",
"editAnnotate": "编辑与注释",
"convertToPdf": "转换为 PDF",
@@ -10,6 +11,13 @@
"input": "输入",
"output": "输出"
},
"favorites": {
"add": "添加到收藏",
"remove": "从收藏中移除",
"hintLabel": "提示",
"hint": "长按工具即可将其添加到收藏。",
"hideHint": "隐藏提示"
},
"convertToPdf": "转换为 PDF",
"pdfMultiTool": {
"name": "PDF 多功能工具",
+4 -2
View File
@@ -1211,7 +1211,8 @@ body.lp ::selection {
}
.lp .category-group:first-child,
.lp #search-results + .category-group {
.lp #search-results + .category-group,
.lp .tool-fav-hint + .category-group {
margin-top: 0;
}
@@ -1301,7 +1302,8 @@ body.lp ::selection {
outline-offset: -2px;
}
.lp #tool-grid:not(.compact-mode) .tool-card:hover {
.lp #tool-grid:not(.compact-mode) .tool-card:hover,
.lp #tool-grid:not(.compact-mode) .tool-tile:hover .tool-card {
background: var(--lp-bg-3);
transform: none;
box-shadow: none;
+190
View File
@@ -123,6 +123,196 @@ body {
transform: rotate(-90deg);
}
.tool-tile {
position: relative;
display: flex;
min-width: 0;
}
.tool-tile > .tool-card {
flex: 1;
min-width: 0;
}
.tool-fav-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 0;
border-radius: 0.375rem;
background: none;
color: #9ca3af;
cursor: pointer;
opacity: 0;
transition:
opacity 0.2s,
color 0.2s;
}
.tool-fav-btn svg {
width: 1.125rem;
height: 1.125rem;
fill: currentColor;
}
.tool-fav-btn .tool-fav-on,
.tool-fav-btn.is-favorite .tool-fav-off {
display: none;
}
.tool-fav-btn.is-favorite .tool-fav-on {
display: inline;
}
.tool-fav-btn:focus-visible,
.tool-fav-btn.is-favorite {
opacity: 1;
}
@media (hover: hover) {
.tool-tile:hover .tool-fav-btn {
opacity: 1;
}
}
.tool-fav-btn:hover {
color: #ffffff;
}
.tool-fav-btn.is-favorite {
color: #e3c46b;
}
.tool-fav-btn:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 0;
}
.tool-fav-menu,
.tool-fav-hint {
display: none;
}
@media (hover: none) {
.tool-fav-btn:not(.is-favorite) {
visibility: hidden;
}
.tool-tile > .tool-card {
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-user-select: none;
user-select: none;
}
.tool-fav-menu {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
z-index: 2;
display: block;
animation: tool-fav-menu-in 0.15s ease-out;
}
.tool-fav-menu-btn {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
background: rgb(52, 52, 52);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
color: #f3f3f1;
font-size: 0.9375rem;
font-weight: 500;
text-align: left;
}
.tool-fav-menu-btn svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
fill: #e3c46b;
}
.tool-fav-menu-btn .tool-fav-on,
.tool-fav-menu-btn.is-favorite .tool-fav-off {
display: none;
}
.tool-fav-menu-btn.is-favorite .tool-fav-on {
display: inline;
}
.tool-fav-hint {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding-block: 0.875rem;
padding-inline: 1.125rem 0.5rem;
border: 1px solid var(--lp-line-2, rgba(255, 255, 255, 0.2));
background: var(--lp-bg-2, #2a2a2a);
color: var(--lp-text-2, #b4b4b0);
font-size: 0.875rem;
line-height: 1.45;
}
.tool-fav-hint > span {
flex: 1;
}
.tool-fav-hint-label {
color: var(--lp-accent-3, #a5b4fc);
font-weight: 600;
}
.tool-fav-hint-close {
display: grid;
place-items: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
padding: 0;
border: 0;
background: none;
color: var(--lp-text-2, #b4b4b0);
font-size: 1rem;
}
#search-results:not(.hidden) ~ .tool-fav-hint {
display: none;
}
.compact-mode .tool-fav-menu {
top: calc(100% - 0.25rem);
bottom: auto;
}
}
@keyframes tool-fav-menu-in {
from {
opacity: 0;
transform: translateY(4px);
}
}
.compact-mode .tool-tile > .tool-card {
padding-right: 2.75rem;
}
.compact-mode .tool-fav-btn {
top: calc(50% - 1rem);
}
.compact-mode .category-tools {
grid-template-columns: 1fr !important;
gap: 0 !important;
+405 -45
View File
@@ -30,6 +30,18 @@ import {
removeStoredItem,
} from './utils/safe-storage.js';
import { sanitizeTranslationHtml } from './utils/translation-html.js';
import type { ToolCategory } from '@/types';
const FAVORITE_STAR_PATHS = [
[
'off',
'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z',
],
[
'on',
'M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z',
],
] as const;
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -159,6 +171,7 @@ const init = async () => {
}
const categoryTranslationKeys: Record<string, string> = {
Favorites: 'tools:categories.favorites',
'Popular Tools': 'tools:categories.popularTools',
'Edit & Annotate': 'tools:categories.editAnnotate',
'Convert to PDF': 'tools:categories.convertToPdf',
@@ -313,7 +326,277 @@ const init = async () => {
}))
.filter((category) => category.tools.length > 0);
filteredCategories.forEach((category) => {
const availableTools = new Map<string, ToolCategory['tools'][number]>();
filteredCategories.forEach((category) =>
category.tools.forEach((tool) => {
if (!availableTools.has(tool.id)) availableTools.set(tool.id, tool);
})
);
let favoriteToolIds: string[] = [];
let unavailableFavoriteIds: string[] = [];
function loadFavorites() {
let stored: unknown = [];
try {
stored = JSON.parse(getStoredItem('favoriteTools') || '[]');
} catch {
removeStoredItem('favoriteTools');
}
const ids = Array.isArray(stored)
? [
...new Set(
stored.filter((id): id is string => typeof id === 'string')
),
]
: [];
favoriteToolIds = ids.filter((id) => availableTools.has(id));
unavailableFavoriteIds = ids.filter((id) => !availableTools.has(id));
}
loadFavorites();
function createFavoriteButton(toolId: string): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tool-fav-btn';
button.dataset.favId = toolId;
button.appendChild(createStarIcon());
updateFavoriteButton(button);
return button;
}
function createStarIcon(): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 256 256');
svg.setAttribute('aria-hidden', 'true');
for (const [kind, d] of FAVORITE_STAR_PATHS) {
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute('class', `tool-fav-${kind}`);
path.setAttribute('d', d);
svg.appendChild(path);
}
return svg;
}
const favoritesHint = document.createElement('div');
favoritesHint.className = 'tool-fav-hint col-span-full';
const favoritesHintText = document.createElement('span');
const favoritesHintLabel = document.createElement('strong');
favoritesHintLabel.className = 'tool-fav-hint-label';
favoritesHintLabel.textContent = t('tools:favorites.hintLabel');
favoritesHintText.append(
favoritesHintLabel,
document.createTextNode(` ${t('tools:favorites.hint')}`)
);
const favoritesHintClose = document.createElement('button');
favoritesHintClose.type = 'button';
favoritesHintClose.className = 'tool-fav-hint-close';
favoritesHintClose.setAttribute(
'aria-label',
t('tools:favorites.hideHint')
);
const favoritesHintIcon = document.createElement('i');
favoritesHintIcon.className = 'ph ph-x';
favoritesHintIcon.setAttribute('aria-hidden', 'true');
favoritesHintClose.appendChild(favoritesHintIcon);
favoritesHint.append(favoritesHintText, favoritesHintClose);
favoritesHintClose.addEventListener('click', () => {
setStoredItem('favoritesHintHidden', 'true');
favoritesHint.remove();
});
function updateFavoritesHint() {
if (
favoriteToolIds.length > 0 ||
getStoredItem('favoritesHintHidden') === 'true'
) {
favoritesHint.remove();
return;
}
if (favoritesHint.isConnected) return;
const results = document.getElementById('search-results');
dom.toolGrid.insertBefore(
favoritesHint,
results ? results.nextSibling : dom.toolGrid.firstChild
);
}
const favoriteMenu = document.createElement('div');
favoriteMenu.className = 'tool-fav-menu';
const favoriteMenuButton = document.createElement('button');
favoriteMenuButton.type = 'button';
favoriteMenuButton.className = 'tool-fav-menu-btn';
const favoriteMenuLabel = document.createElement('span');
favoriteMenuButton.append(createStarIcon(), favoriteMenuLabel);
favoriteMenu.appendChild(favoriteMenuButton);
let favoriteMenuStar: HTMLElement | null = null;
function openFavoriteMenu(tile: HTMLElement) {
const star = tile.querySelector<HTMLElement>('.tool-fav-btn');
if (!star) return;
favoriteMenuStar = star;
const active = favoriteToolIds.includes(star.dataset.favId || '');
favoriteMenuButton.classList.toggle('is-favorite', active);
favoriteMenuLabel.textContent = active
? t('tools:favorites.remove')
: t('tools:favorites.add');
tile.appendChild(favoriteMenu);
if (navigator.userActivation?.hasBeenActive) navigator.vibrate?.(10);
}
function closeFavoriteMenu() {
favoriteMenu.remove();
favoriteMenuStar = null;
}
favoriteMenuButton.addEventListener('click', (event) => {
event.stopPropagation();
const star = favoriteMenuStar;
closeFavoriteMenu();
if (star) toggleFavorite(star);
});
function updateFavoriteButton(button: HTMLElement) {
const active = favoriteToolIds.includes(button.dataset.favId || '');
button.classList.toggle('is-favorite', active);
button.setAttribute('aria-pressed', String(active));
const label = active
? t('tools:favorites.remove')
: t('tools:favorites.add');
button.setAttribute('aria-label', label);
button.title = label;
}
function renderFavorites() {
const tools = favoriteToolIds.flatMap((id) => {
const tool = availableTools.get(id);
return tool ? [tool] : [];
});
if (tools.length === 0) return;
const group = createCategoryGroup({ name: 'Favorites', tools });
const searchBox = document.getElementById(
'search-bar'
) as HTMLInputElement | null;
if (searchBox?.value.trim()) group.style.display = 'none';
const results = document.getElementById('search-results');
dom.toolGrid.insertBefore(
group,
results ? results.nextSibling : dom.toolGrid.firstChild
);
createIcons({ icons });
}
function toggleFavorite(button: HTMLElement) {
const toolId = button.dataset.favId;
const tool = toolId ? availableTools.get(toolId) : undefined;
if (!toolId || !tool) return;
const adding = !favoriteToolIds.includes(toolId);
favoriteToolIds = adding
? [...favoriteToolIds, toolId]
: favoriteToolIds.filter((id) => id !== toolId);
setStoredItem(
'favoriteTools',
JSON.stringify([...favoriteToolIds, ...unavailableFavoriteIds])
);
const anchorTop = button.getBoundingClientRect().top;
const keyboardFocus = button.matches(':focus-visible');
const group = dom.toolGrid.querySelector<HTMLElement>(
':scope > .is-cat-favorites'
);
let nextFocus: HTMLElement | null = null;
if (adding && group) {
group
.querySelector('.category-tools')
?.appendChild(createToolTile(tool));
createIcons({ icons });
} else if (adding) {
renderFavorites();
} else if (group) {
const tile = group
.querySelector(`.tool-fav-btn[data-fav-id="${CSS.escape(toolId)}"]`)
?.closest('.tool-tile');
if (tile?.contains(button)) {
const neighbor =
tile.nextElementSibling ?? tile.previousElementSibling;
nextFocus =
neighbor?.querySelector<HTMLElement>('.tool-fav-btn') ??
group.nextElementSibling?.querySelector<HTMLElement>(
'.category-header'
) ??
null;
}
tile?.remove();
if (favoriteToolIds.length === 0) group.remove();
}
dom.toolGrid
.querySelectorAll<HTMLElement>('.tool-fav-btn')
.forEach((other) => {
if (other.dataset.favId === toolId) updateFavoriteButton(other);
});
updateFavoritesHint();
if (button.isConnected) {
const shift = button.getBoundingClientRect().top - anchorTop;
if (shift) window.scrollBy({ top: shift, behavior: 'instant' });
} else if (keyboardFocus) {
nextFocus?.focus({ preventScroll: true });
}
}
function createToolTile(tool: ToolCategory['tools'][number]) {
const tile = document.createElement('div');
tile.className = 'tool-tile';
let toolCard: HTMLDivElement | HTMLAnchorElement;
if (tool.href) {
toolCard = document.createElement('a');
toolCard.href = tool.href;
toolCard.className =
'tool-card block bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center no-underline hover:shadow-lg transition duration-200';
} else {
toolCard = document.createElement('div');
toolCard.className =
'tool-card bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center hover:shadow-lg transition duration-200';
toolCard.dataset.toolId = getToolId(tool);
}
const icon = document.createElement('i');
icon.className = 'w-10 h-10 mb-3 text-indigo-400';
if (tool.icon.startsWith('ph-')) {
icon.className = `ph ${tool.icon} text-4xl mb-3 text-indigo-400`;
} else {
icon.setAttribute('data-lucide', tool.icon);
}
const toolName = document.createElement('h3');
toolName.className = 'font-semibold text-white';
const toolKey = toolTranslationKeys[tool.name];
toolName.textContent = toolKey ? t(`${toolKey}.name`) : tool.name;
toolCard.append(icon, toolName);
if (tool.subtitle) {
const toolSubtitle = document.createElement('p');
toolSubtitle.className = 'text-xs text-gray-400 mt-1 px-2';
toolSubtitle.textContent = toolKey
? t(`${toolKey}.subtitle`)
: tool.subtitle;
toolCard.appendChild(toolSubtitle);
}
tile.append(toolCard, createFavoriteButton(tool.id));
return tile;
}
function createCategoryGroup(category: ToolCategory): HTMLDivElement {
const categoryGroup = document.createElement('div');
const categorySlug = category.name
.toLowerCase()
@@ -376,69 +659,40 @@ const init = async () => {
});
category.tools.forEach((tool) => {
let toolCard: HTMLDivElement | HTMLAnchorElement;
if (tool.href) {
toolCard = document.createElement('a');
toolCard.href = tool.href;
toolCard.className =
'tool-card block bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center no-underline hover:shadow-lg transition duration-200';
} else {
toolCard = document.createElement('div');
toolCard.className =
'tool-card bg-gray-800 rounded-xl p-4 cursor-pointer flex flex-col items-center justify-center text-center hover:shadow-lg transition duration-200';
toolCard.dataset.toolId = getToolId(tool);
}
const icon = document.createElement('i');
icon.className = 'w-10 h-10 mb-3 text-indigo-400';
if (tool.icon.startsWith('ph-')) {
icon.className = `ph ${tool.icon} text-4xl mb-3 text-indigo-400`;
} else {
icon.setAttribute('data-lucide', tool.icon);
}
const toolName = document.createElement('h3');
toolName.className = 'font-semibold text-white';
const toolKey = toolTranslationKeys[tool.name];
toolName.textContent = toolKey ? t(`${toolKey}.name`) : tool.name;
toolCard.append(icon, toolName);
if (tool.subtitle) {
const toolSubtitle = document.createElement('p');
toolSubtitle.className = 'text-xs text-gray-400 mt-1 px-2';
toolSubtitle.textContent = toolKey
? t(`${toolKey}.subtitle`)
: tool.subtitle;
toolCard.appendChild(toolSubtitle);
}
toolsContainer.appendChild(toolCard);
toolsContainer.appendChild(createToolTile(tool));
});
categoryGroup.append(header, toolsContainer);
dom.toolGrid.appendChild(categoryGroup);
if (!isCollapsed) {
toolsContainer.style.maxHeight = 'none';
toolsContainer.style.overflow = 'visible';
}
return categoryGroup;
}
filteredCategories.forEach((category) => {
dom.toolGrid.appendChild(createCategoryGroup(category));
});
const searchBar = document.getElementById('search-bar');
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
const searchResultsContainer = document.createElement('div');
searchResultsContainer.id = 'search-results';
searchResultsContainer.className =
'hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6 col-span-full';
dom.toolGrid.insertBefore(searchResultsContainer, dom.toolGrid.firstChild);
renderFavorites();
updateFavoritesHint();
searchBar.addEventListener('input', () => {
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
const searchTerm = searchBar.value.toLowerCase().trim();
closeFavoriteMenu();
const categoryGroups = dom.toolGrid.querySelectorAll(
':scope > .category-group'
);
if (!searchTerm) {
searchResultsContainer.classList.add('hidden');
@@ -487,7 +741,11 @@ const init = async () => {
if (isMatch && !isDuplicate) {
seenToolIds.add(toolId);
allTools.push(card.cloneNode(true) as HTMLElement);
allTools.push(
(card.closest('.tool-tile') ?? card).cloneNode(
true
) as HTMLElement
);
}
});
});
@@ -511,8 +769,110 @@ const init = async () => {
}
});
dom.toolGrid.addEventListener('click', () => {
dom.toolGrid.addEventListener('click', (event) => {
// All tools now use href and navigate directly - no modal handling needed
const favoriteButton = (event.target as Element).closest<HTMLElement>(
'.tool-fav-btn'
);
if (favoriteButton) toggleFavorite(favoriteButton);
});
let pressTimer = 0;
let pressStart: { x: number; y: number } | null = null;
let suppressClick = false;
const touchOnly = window.matchMedia('(hover: none)');
function cancelPress() {
window.clearTimeout(pressTimer);
pressStart = null;
}
function pressedTile(target: EventTarget | null): HTMLElement | null {
const element = target instanceof Element ? target : null;
if (!element || element.closest('.tool-fav-btn, .tool-fav-menu')) {
return null;
}
return element.closest<HTMLElement>('.tool-tile');
}
dom.toolGrid.addEventListener('pointerdown', (event) => {
suppressClick = false;
if (event.pointerType !== 'touch' || !touchOnly.matches) return;
const tile = pressedTile(event.target);
if (!tile) return;
pressStart = { x: event.clientX, y: event.clientY };
pressTimer = window.setTimeout(() => {
pressStart = null;
suppressClick = true;
openFavoriteMenu(tile);
}, 500);
});
dom.toolGrid.addEventListener('pointermove', (event) => {
if (
pressStart &&
Math.hypot(event.clientX - pressStart.x, event.clientY - pressStart.y) >
10
) {
cancelPress();
}
});
dom.toolGrid.addEventListener('pointerup', cancelPress);
dom.toolGrid.addEventListener('pointercancel', cancelPress);
dom.toolGrid.addEventListener('contextmenu', (event) => {
if (!touchOnly.matches) return;
const tile = pressedTile(event.target);
if (!tile) return;
event.preventDefault();
cancelPress();
if (!tile.contains(favoriteMenu)) {
suppressClick = true;
openFavoriteMenu(tile);
}
});
dom.toolGrid.addEventListener(
'click',
(event) => {
if (!suppressClick) return;
suppressClick = false;
event.preventDefault();
event.stopPropagation();
},
true
);
document.addEventListener('pointerdown', (event) => {
if (
favoriteMenu.isConnected &&
!(event.target instanceof Node && favoriteMenu.contains(event.target))
) {
closeFavoriteMenu();
}
});
window.addEventListener('scroll', closeFavoriteMenu, { passive: true });
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeFavoriteMenu();
});
window.addEventListener('storage', (event) => {
if (
event.key !== null &&
event.key !== 'favoriteTools' &&
event.key !== 'favoritesHintHidden'
) {
return;
}
closeFavoriteMenu();
loadFavorites();
dom.toolGrid.querySelector(':scope > .is-cat-favorites')?.remove();
renderFavorites();
dom.toolGrid
.querySelectorAll<HTMLElement>('.tool-fav-btn')
.forEach(updateFavoriteButton);
updateFavoritesHint();
});
}
+1
View File
@@ -71,3 +71,4 @@ export * from './edit-pdf-redaction-type.ts';
export * from './validate-signature-extraction-type.ts';
export * from './pdf-content-tokens-type.ts';
export * from './compress-pdf-type.ts';
export * from './tool-category-type.ts';
+3
View File
@@ -0,0 +1,3 @@
import type { categories } from '../config/tools.ts';
export type ToolCategory = (typeof categories)[number];