NPM Favorites ❤

Will allow you to easily organize and sort packages you might want to use in the future

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         NPM Favorites ❤
// @namespace    http://tampermonkey.net/
// @version      2024-08-11
// @description  Will allow you to easily organize and sort packages you might want to use in the future
// @author       GV3Dev
// @match        https://www.npmjs.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=npmjs.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @require      http://code.jquery.com/jquery-latest.js
// @require      https://code.jquery.com/ui/1.12.1/jquery-ui.js
// @license MIT
// ==/UserScript==

let $ = window.jQuery;
var j = $.noConflict();

const main = async () => {
    if (location.href.includes("https://www.npmjs.com")) {
        setupPage(); monitorUrlChanges();
    }
}
main();


function setupPage() {
    const mainMenu = document.querySelector("#main-menu");
    if (!mainMenu) return;
    let bookmarksBtn = document.querySelector("#open-favorites-npm");
    if (!bookmarksBtn) {
        bookmarksBtn = document.createElement("li");
        bookmarksBtn.innerHTML = `<a style="cursor:pointer;" role="menuitem" class="c6c55db4 no-underline f6-ns f7 fw5 dim pr2 pl2" id="open-favorites-npm">Favorites</a>`;
        bookmarksBtn.className = "dib";
        bookmarksBtn.title = "view favorited packages ♥";
        mainMenu.append(bookmarksBtn);
        bookmarksBtn.addEventListener("click", openFavorites);
    }
    const heart = mainMenu.parentElement.parentElement.querySelector("span");
    if (location.href.includes("https://www.npmjs.com/package/")) {
        const packageName = location.href.split("/").pop();
        const savedPackage = GM_getValue(packageName);
        heart.style = "cursor:pointer; transition: .5s;";
        heart.title = savedPackage ? `Remove package from favorites` : `Add package to favorites`;
        heart.style.color = savedPackage ? "red" : "";
        heart.addEventListener("click", () => {
            toggleFavorite(packageName, heart);
        });
        if (savedPackage) {
            addHeartEmojiToHeader();
        }else{
            removeHeartEmojiFromHeader();
        }
    }else{
        heart.title = ""; heart.style = "";
    }
}

function openFavorites(evt) {
    evt.preventDefault();

    let menu = document.querySelector('#favorites-menu-npm');
    if (menu) {
        menu.style.display = menu.style.display === 'none' ? 'flex' : 'none';
        if (menu.style.display === 'flex') {
            const toBeFilled = document.querySelector("#fav-contain");
            populateFavorites(toBeFilled);
        }
    } else {
        menu = document.createElement('div');
        menu.id = 'favorites-menu-npm';
        menu.style.cssText = `
            position: fixed; top: 10px;
            right: 10px; width: 300px;
            min-height: 250px; max-height: 500px; background-color: #fff;
            border: 1px solid lightgray;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
            z-index: 1000; display: flex;
            justify-content:flex-start;align-items:center;
            flex-direction:column;border-radius:5px;
            padding:5px; font-family: 'Source Sans Pro', 'Lucida Grande', sans-serif;
        `;
        menu.innerHTML = `
            <h2 style="width:100%; text-align:center;margin-bottom:0; padding-bottom:0;">NPM Favorites <span style="color:red">❤</span></h2>
            <p style="text-align:center;width:95%;">Your favorite packages, within reach!</p>
            <div style="display:flex;justify-content:flex-start;align-items:center;flex-direction:row;background-color:rgba(0,0,0,0.04);padding:8px;padding-left:12px;border-radius:5px;width:90%;margin-top:5px;margin-bottom:10px;">
              <svg width="15px" height="15px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" aria-hidden="true"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g stroke="#777777" stroke-width="1.3"><g><path d="M13.4044,7.0274 C13.4044,10.5494 10.5494,13.4044 7.0274,13.4044 C3.5054,13.4044 0.6504,10.5494 0.6504,7.0274 C0.6504,3.5054 3.5054,0.6504 7.0274,0.6504 C10.5494,0.6504 13.4044,3.5054 13.4044,7.0274 Z"></path><path d="M11.4913,11.4913 L17.8683,17.8683"></path></g></g></g></svg>
              <input id="search-fav-npm" type="text" placeholder="Search favorites" style="outline:none;border:none;background-color:transparent; padding-left:10px; font-family: var(--code); font-size:12px;">
            </div>
            <div id="fav-contain" style="margin-top:5px; width:95%; height:fit-content; max-height:85%; overflow:hidden; overflow-y:auto; padding:5px; margin-bottom:10px;"></div>
            <p style="padding:0;margin:0;text-align:center;margin-top:15px;margin-bottom:20px;font-size:13px;opacity:0.85;">Brought to you by <a href="https://github.com/gv3dev" target="_blank" style="font-weight:bold; color:red; cursor:pointer; text-decoration:none;">GV3Dev</a<p>
        `;
        document.body.appendChild(menu);
        $(menu).draggable();
        injectScrollbarCSS();
        const searchBar = menu.querySelector("#search-fav-npm");
        const toBeFilled = document.querySelector("#fav-contain");
        populateFavorites(toBeFilled);
        searchBar.addEventListener("keyup", (evt)=>{handleSearch(evt, toBeFilled)})
    }
}


function handleSearch(evt, toBeFilled) {
    const searchQuery = evt.target.value.toLowerCase();
    const favorites = GM_listValues();
    if (searchQuery === '') {
        populateFavorites(toBeFilled);
    } else {
        const filteredFavorites = favorites.filter(packageName => {
            const packageData = GM_getValue(packageName);
            return packageName.toLowerCase().includes(searchQuery) ||
                (packageData.description && packageData.description.toLowerCase().includes(searchQuery));
        });
        populateFavorites(toBeFilled, filteredFavorites);
    }
}

function populateFavorites(menu, filteredFavorites = null) {
    menu.innerHTML = '';
    const favorites = filteredFavorites || GM_listValues();

    if (favorites.length > 0) {
        const sortedFavorites = favorites
        .map(packageName => {
            const packageData = GM_getValue(packageName);
            return {
                name: packageName,
                data: packageData
            };
        })
        .sort((a, b) => new Date(b.data.addedAt) - new Date(a.data.addedAt));

        sortedFavorites.forEach(({ name, data }) => {
            let item = document.createElement("div");
            item.style = `
                width: 100%;
                min-height: 50px;
                height: fit-content;
                border-bottom: 1px solid rgba(0,0,0,0.05);
                display: flex;
                align-items: flex-start;
                justify-content: center;
                flex-direction: column;
                padding: 5px;
                padding-bottom:8px;
                margin-top:5px;
                margin-bottom:5px;
                cursor:pointer;
                transition: background-color 0.3s ease;
            `;
            item.innerHTML = `
                <span style="width: 100%; display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 5px;">
                    <h2 style="margin: 0; font-size: 16px;">${name}</h2>
                    <span title="remove from favorites" style="cursor: pointer; transition: color 0.3s; font-size: 18px; padding: 2px 5px;" class="removeFavorite">&times;</span>
                </span>
                <p style="margin: 0; font-size: 14px; color: gray; text-align:left;">${data.description.length > 0 ? data.description : "For use in future projects."}</p>
            `;
            item.addEventListener('mouseover', () => {
                item.style.backgroundColor = '#f5f5f5';
            });

            item.addEventListener('mouseout', () => {
                item.style.backgroundColor = '';
            });
            item.addEventListener('click', () => {
                window.location.href = data.url;
            });

            item.querySelector('.removeFavorite').addEventListener('click', (e) => {
                e.stopPropagation();
                GM_deleteValue(name);
                populateFavorites(menu);
            });
            item.querySelector('.removeFavorite').addEventListener('mouseover', (e) => {
                e.target.style.color = 'red';
            });

            item.querySelector('.removeFavorite').addEventListener('mouseout', (e) => {
                e.target.style.color = '';
            });
            menu.append(item);
        });
    } else {
        menu.innerHTML = `<p style="text-align:center;">You have no favorites</p>`;
    }
}


function toggleFavorite(packageName, btn) {
    if (location.href.includes("https://www.npmjs.com/package/")) {
        const savedPackage = GM_getValue(packageName);

        if (savedPackage) {
            GM_deleteValue(packageName);
            btn.style = "color:; transition:.5s; cursor:pointer;";
            btn.title = `Add package to favorites`;
            removeHeartEmojiFromHeader();
        } else {
            const description = prompt("💡 Add a reminder\n\nWhat do you plan to use this package for? Describe it here to help you remember later!\n\nYou can leave empty if you would like.\n","For use in future projects.");
            if (description !== null) {
                GM_setValue(packageName, { url: location.href, description, addedAt: new Date().toISOString() });
                btn.style = "color:red; transition:.5s; cursor:pointer;";
                btn.title = `Remove package from favorites`;
                addHeartEmojiToHeader();
            }
        }
    }
}

function addHeartEmojiToHeader() {
    const header = document.querySelector("#top").firstChild.firstChild;
    if (header && !header.innerText.includes("😍")) {
        header.innerText += "😍";
        header.title = "You have favorited this package ♥";
    }
}

function removeHeartEmojiFromHeader() {
    const header = document.querySelector("#top").firstChild.firstChild;
    if (header) {
        header.innerText = header.innerText.replace("😍", "");
        header.title = "";
    }
}




function monitorUrlChanges() {
    let previousUrl = location.href;
    setInterval(() => {
        const currentUrl = location.href;
        if (currentUrl !== previousUrl) {
            previousUrl = currentUrl;
            setupPage();
        }
    }, 100);
}


// helper functions

function injectScrollbarCSS() {
    const css = `
            #fav-contain::-webkit-scrollbar {
                width: 5px;
            }
            #fav-contain::-webkit-scrollbar-track {
                background: #f1f1f1;
                border-radius: 10px;
            }
            #fav-contain::-webkit-scrollbar-thumb {
                background: rgba(0,0,0,0.2);
                border-radius: 10px;
            }
            #fav-contain::-webkit-scrollbar-thumb:hover {
                background:rgba(0,0,0,0.5);
            }
        `;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
}

async function waitForElem(selector, all = false) {
    return new Promise((resolve) => {
        const checkElements = () => {
            const elements = all ? document.querySelectorAll(selector) : document.querySelector(selector);
            if (!all) {
                if (elements) {
                    resolve(elements);
                } else {
                    requestAnimationFrame(checkElements);
                }
            } else {
                if (elements.length > 0) {
                    resolve(elements);
                } else {
                    requestAnimationFrame(checkElements);
                }
            }
        };
        checkElements();
    });
}