From 7eec9a0d644a01fcc01cb896ff6720fd52446868 Mon Sep 17 00:00:00 2001 From: alam00000 Date: Thu, 22 Oct 2026 15:04:42 +0530 Subject: [PATCH] feat: add favorites functionality to tools --- public/locales/ar/tools.json | 8 + public/locales/be/tools.json | 8 + public/locales/ca/tools.json | 8 + public/locales/da/tools.json | 8 + public/locales/de/tools.json | 8 + public/locales/en/tools.json | 8 + public/locales/es/tools.json | 8 + public/locales/fa/tools.json | 8 + public/locales/fr/tools.json | 8 + public/locales/id/tools.json | 8 + public/locales/it/tools.json | 8 + public/locales/ja/tools.json | 8 + public/locales/ko/tools.json | 8 + public/locales/nl/tools.json | 8 + public/locales/pl/tools.json | 8 + public/locales/pt/tools.json | 8 + public/locales/ru/tools.json | 8 + public/locales/sk/tools.json | 8 + public/locales/sv/tools.json | 8 + public/locales/tr/tools.json | 8 + public/locales/uk/tools.json | 8 + public/locales/vi/tools.json | 8 + public/locales/zh-TW/tools.json | 8 + public/locales/zh/tools.json | 8 + src/css/landing.css | 6 +- src/css/styles.css | 190 ++++++++++++ src/js/main.ts | 450 ++++++++++++++++++++++++++--- src/js/types/index.ts | 1 + src/js/types/tool-category-type.ts | 3 + 29 files changed, 795 insertions(+), 47 deletions(-) create mode 100644 src/js/types/tool-category-type.ts diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json index 12f7c8aa..367dd317 100644 --- a/public/locales/ar/tools.json +++ b/public/locales/ar/tools.json @@ -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 المتعددة", diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json index 1fd40b3f..45e8ff53 100644 --- a/public/locales/be/tools.json +++ b/public/locales/be/tools.json @@ -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", diff --git a/public/locales/ca/tools.json b/public/locales/ca/tools.json index 5efa0cd7..cede54fe 100644 --- a/public/locales/ca/tools.json +++ b/public/locales/ca/tools.json @@ -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ó", diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json index 4ea3503f..31a9eae6 100644 --- a/public/locales/da/tools.json +++ b/public/locales/da/tools.json @@ -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", diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index 8131602a..001eac54 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -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", diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index 16128407..609be47d 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -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", diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json index 680d97f5..d5008004 100644 --- a/public/locales/es/tools.json +++ b/public/locales/es/tools.json @@ -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", diff --git a/public/locales/fa/tools.json b/public/locales/fa/tools.json index 9f866770..560417fa 100644 --- a/public/locales/fa/tools.json +++ b/public/locales/fa/tools.json @@ -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", diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 93e1dcff..d0059ee0 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -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", diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index d7648eac..0b32483b 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -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", diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index e26441f5..8a4a1479 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -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", diff --git a/public/locales/ja/tools.json b/public/locales/ja/tools.json index 47621da8..2cb20578 100644 --- a/public/locales/ja/tools.json +++ b/public/locales/ja/tools.json @@ -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マルチツール", diff --git a/public/locales/ko/tools.json b/public/locales/ko/tools.json index d3d36196..d7d4d81d 100644 --- a/public/locales/ko/tools.json +++ b/public/locales/ko/tools.json @@ -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 멀티 도구", diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index 1059a8f4..44c3e6c9 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -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", diff --git a/public/locales/pl/tools.json b/public/locales/pl/tools.json index 414b2bd5..d765e941 100644 --- a/public/locales/pl/tools.json +++ b/public/locales/pl/tools.json @@ -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", diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json index 0fa23d79..41b287c2 100644 --- a/public/locales/pt/tools.json +++ b/public/locales/pt/tools.json @@ -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", diff --git a/public/locales/ru/tools.json b/public/locales/ru/tools.json index 0f7e931d..60b37754 100644 --- a/public/locales/ru/tools.json +++ b/public/locales/ru/tools.json @@ -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 мультиинструмент", diff --git a/public/locales/sk/tools.json b/public/locales/sk/tools.json index b4020a87..248a877d 100644 --- a/public/locales/sk/tools.json +++ b/public/locales/sk/tools.json @@ -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", diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json index 939e3bc1..805a9bdb 100644 --- a/public/locales/sv/tools.json +++ b/public/locales/sv/tools.json @@ -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", diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 7bf39a63..db74f17e 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -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ç", diff --git a/public/locales/uk/tools.json b/public/locales/uk/tools.json index 3828b80d..dc56df5e 100644 --- a/public/locales/uk/tools.json +++ b/public/locales/uk/tools.json @@ -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 мультиінструмент", diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index 62b2741d..15269e0e 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -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", diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json index 8b2845a8..34178de7 100644 --- a/public/locales/zh-TW/tools.json +++ b/public/locales/zh-TW/tools.json @@ -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 多功能工具", diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index 2f6635cc..92438621 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -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 多功能工具", diff --git a/src/css/landing.css b/src/css/landing.css index fb793816..76891e96 100644 --- a/src/css/landing.css +++ b/src/css/landing.css @@ -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; diff --git a/src/css/styles.css b/src/css/styles.css index cd1a7796..2168d07c 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; diff --git a/src/js/main.ts b/src/js/main.ts index 51583619..7fe19a61 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -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 = { + 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(); + 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('.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( + ':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('.tool-fav-btn') ?? + group.nextElementSibling?.querySelector( + '.category-header' + ) ?? + null; + } + tile?.remove(); + if (favoriteToolIds.length === 0) group.remove(); + } + + dom.toolGrid + .querySelectorAll('.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( + '.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('.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('.tool-fav-btn') + .forEach(updateFavoriteButton); + updateFavoritesHint(); }); } diff --git a/src/js/types/index.ts b/src/js/types/index.ts index 1a9624af..cf843835 100644 --- a/src/js/types/index.ts +++ b/src/js/types/index.ts @@ -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'; diff --git a/src/js/types/tool-category-type.ts b/src/js/types/tool-category-type.ts new file mode 100644 index 00000000..5e925404 --- /dev/null +++ b/src/js/types/tool-category-type.ts @@ -0,0 +1,3 @@ +import type { categories } from '../config/tools.ts'; + +export type ToolCategory = (typeof categories)[number];