StashDB Favorites

Add a new favorite page with persistent syncable storage

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         StashDB Favorites
// @namespace    https://greasyfork.org/fr/users/1468290-payamarre
// @version      1.2
// @author       NoOne
// @description  Add a new favorite page with persistent syncable storage
// @match        https://stashdb.org/*
// @icon         https://cdn-icons-png.flaticon.com/512/4784/4784090.png
// @license MIT
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// ==/UserScript==

(function() {
    'use strict';

    const FAVORITES_KEY = 'stash_favorites';
    const SUPER_KEY = 'stash_super_favorites';
    const SUPER_VISIBLE_KEY = 'stash_super_visible';
    const SORT_STATE_KEY = 'stash_sort_state';
    const TAG_COLORS_KEY = 'stash_tag_colors';

    let favoritesCache = JSON.parse(GM_getValue(FAVORITES_KEY, '[]'));
    let superCache = JSON.parse(GM_getValue(SUPER_KEY, '[]'));
    let tagColorsCache = JSON.parse(GM_getValue(TAG_COLORS_KEY, '{}'));

    const getFavorites = () => favoritesCache;

    const saveFavorites = favs => {
        favoritesCache = favs;
        GM_setValue(FAVORITES_KEY, JSON.stringify(favs));
        syncSuperWithFavorites();
    };

    const getSuper = () => superCache;

    const saveSuper = sup => {
        superCache = sup;
        GM_setValue(SUPER_KEY, JSON.stringify(sup));
    };

    const getSortState = () =>
        GM_getValue(SORT_STATE_KEY, 'none');

    const saveSortState = state =>
        GM_setValue(SORT_STATE_KEY, state);

    const getTagColors = () => tagColorsCache;

    const saveTagColors = colors => {
        tagColorsCache = colors;
        GM_setValue(TAG_COLORS_KEY, JSON.stringify(colors));
    };

    const isFavorited = url => getFavorites().some(f => f.url === url);
    const isSuper = url => getSuper().some(f => f.url === url);

    const addFavorite = (url, title, image, date, duration, tags) => {
        const favs = getFavorites();
        if (!favs.some(f => f.url === url)) {
            favs.push({ url, title, image, date, duration, tags });
            saveFavorites(favs);
        }
    };

    const removeFavorite = url => {
        const favs = getFavorites().filter(f => f.url !== url);
        saveFavorites(favs);
    };

    const addSuper = fav => {
        const sup = getSuper();
        if (!sup.some(f => f.url === fav.url)) {
            saveSuper([...sup, Object.assign({}, fav)]);
        }
    };

    const removeSuper = url => {
        const sup = getSuper().filter(f => f.url !== url);
        saveSuper(sup);
    };

    function syncSuperWithFavorites() {
        const favs = getFavorites();
        const sup = getSuper();
        const updated = sup.map(s => favs.find(f => f.url === s.url) || null)
                           .filter(Boolean);
        saveSuper(updated);
    }

    function randomColor() {
        const h = Math.floor(Math.random()*360);
        const s = 60 + Math.floor(Math.random()*20);
        const l = 45 + Math.floor(Math.random()*10);
        return `hsl(${h} ${s}% ${l}%)`;
    }

    function insertSceneFavButton() {
        if (!location.pathname.startsWith('/scenes/')) return;

        const tryInsert = () => {
            const descriptionTab = document.querySelector('#scene-tabs-tab-description');
            if (!descriptionTab || document.getElementById('fav-btn-scene')) return false;

            const dateText = document.querySelector('.card-header h6')
                ?.textContent.trim().split('•').pop().trim() || '';

            const durationText = document.querySelector('.card-footer [title][class*="ms-3"] b')
                ?.textContent.trim() || '';

            const tags = Array.from(document.querySelectorAll('.scene-tags ul.scene-tag-list li a'))
                .map(a => a.textContent.trim());

            const heartBtn = document.createElement('button');
            heartBtn.id = 'fav-btn-scene';

            const heartSpan = document.createElement('span');
            heartSpan.style.cssText =
                'color:#ff4d4d; line-height:0; display:flex; align-items:center; justify-content:center;';
            heartSpan.textContent = isFavorited(location.href) ? '♥' : '♡';

            heartBtn.appendChild(heartSpan);
            Object.assign(heartBtn.style, {
                border: 'none',
                background: 'transparent',
                fontSize: '30px',
                cursor: 'pointer',
                padding: '0 6px 0 0',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                height: '100%',
                verticalAlign: 'middle'
            });

            heartBtn.addEventListener('click', () => {
                const alreadyFav = isFavorited(location.href);
                const title = document.querySelector('[data-multi-button="true"]')
                    ?.textContent.trim() || document.title;

                const image = document.querySelector('.Image-image')?.src || '';

                if (alreadyFav) {
                    removeFavorite(location.href);
                    heartSpan.textContent = '♡';
                } else {
                    addFavorite(location.href, title, image, dateText, durationText, tags);
                    heartSpan.textContent = '♥';
                }
            });

            const tabText = descriptionTab.textContent.trim();
            descriptionTab.textContent = '';
            descriptionTab.appendChild(heartBtn);
            descriptionTab.append(' ' + tabText);

            return true;
        };

        if (!tryInsert()) {
            let attempts = 0;
            const interval = setInterval(() => {
                attempts++;
                if (tryInsert() || attempts > 20) clearInterval(interval);
            }, 300);
        }
    }

    function insertHeaderFavButton() {
        const navbar = document.querySelector('nav.navbar');
        if (!navbar || document.getElementById('fav-btn-header')) return;

        const favLink = document.createElement('a');
        favLink.id = 'fav-btn-header';
        favLink.className = 'nav-link';
        favLink.href = '/favorites';
        favLink.textContent = '❤️';
        favLink.style.fontSize = '20px';
        favLink.style.marginRight = '10px';

        const logout = navbar.querySelector('a[href="/logout"]');
        if (logout && logout.parentNode)
            logout.parentNode.insertBefore(favLink, logout);
        else
            navbar.appendChild(favLink);
    }

    (function hookHistoryEvents(){
        const _push = history.pushState;
        const _replace = history.replaceState;

        history.pushState = function() {
            _push.apply(this, arguments);
            window.dispatchEvent(new Event('locationchange'));
        };
        history.replaceState = function() {
            _replace.apply(this, arguments);
            window.dispatchEvent(new Event('locationchange'));
        };

        window.addEventListener('popstate', () =>
            window.dispatchEvent(new Event('locationchange'))
        );
        window.addEventListener('locationchange', () =>
            setTimeout(init, 150)
        );
    })();

    function renderFavoritesPage() {
        if (location.pathname !== '/favorites') return;
        document.title = 'StashDB Favorites';
        const favicon = document.createElement('link');
        favicon.rel = 'icon';
        favicon.href = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><text y="14" font-size="16">❤️</text></svg>';
        document.head.appendChild(favicon);
        const superVisible = JSON.parse(localStorage.getItem(SUPER_VISIBLE_KEY) ?? 'true');
        const sortState = getSortState();
        document.body.innerHTML = `
            <div style="background-color:#202b33; color:#f5f8fa; min-height:100vh; padding:20px; font-family:sans-serif;">
                <div id="top-row" style="margin-bottom:10px; display:flex; gap:10px; align-items:center; justify-content:space-between;">
                    <div style="display:flex; gap:10px; align-items:center;">
                        <button id="super-toggle-btn">Super Favorites</button>
                        <button id="sort-btn">Sort: ${sortState==='none'?'None':sortState==='asc'?'Oldest':'Newest'}</button>
                        <div style="position:relative; display:flex; align-items:center;">
                            <input type="text" id="search-favs" placeholder="Search..." style="width:260px; padding:6px 36px 6px 10px; border-radius:6px; border:none; background:#30404d; color:#f5f8fa;">
                            <button id="clear-search" style="position:absolute; right:6px; background:none; border:none; color:#ff4d4d; font-size:22px; cursor:pointer; border-radius:6px; padding:4px; width:28px; height:28px; display:flex; align-items:center; justify-content:center; transition:background 0.15s ease, transform 0.1s ease, border-radius 0.15s ease;" onmouseover="this.style.background='#202b33'; this.style.borderRadius='4px';" onmouseout="this.style.background='transparent'; this.style.borderRadius='6px';" onmousedown="this.style.transform='scale(0.95)';" onmouseup="this.style.transform='scale(1)';">×</button>
                        </div>
                    </div>
                    <div style="display:flex; gap:10px;">
                        <button id="import-btn">Import</button>
                        <button id="export-btn">Export</button>
                    </div>
                </div>
                <div id="super-container" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:20px; margin-bottom:10px; overflow:hidden;"></div>
                <div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
                    <span style="font-weight:700; color:#f5f8fa;">Favorites</span>
                    <hr style="flex:1; border:1px solid white; margin:0;">
                </div>
                <div id="fav-container" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:20px;"></div>
            </div>
        `;
        const style = document.createElement('style');
        style.textContent = `
            #super-toggle-btn, #sort-btn, #import-btn, #export-btn {
                background-color:#30404d;
                color:#f5f8fa;
                border:none;
                border-radius:6px;
                padding:6px 12px;
                cursor:pointer;
                font-size:14px;
                font-weight: 700;
                transition: filter 0.15s ease, transform 0.18s ease;
            }
            #super-toggle-btn.opening { transform: scale(1.06); }
            #super-toggle-btn:hover, #sort-btn:hover, #import-btn:hover, #export-btn:hover { filter: brightness(1.15); }
            #super-container {
                display: grid;
                grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
                gap: 20px;
                margin-bottom: 20px;
                overflow: hidden;
                opacity: 1;
                transition: opacity 1s ease;
            }
            #super-container.closed {
              max-height: 0;
              opacity: 0;
            }
            #super-container.hidden { max-height: 0; opacity: 0; }
            .fav-card {
                background: #30404d;
                border-radius: 8px;
                display: flex;
                flex-direction: column;
                position: relative;
                overflow: hidden;
                transition: filter 0.15s ease;
                cursor:pointer;
            }
            .fav-card:hover { filter: brightness(1.12); }
            .fav-title { height:40px; padding: 10px; font-size:14px; font-weight:700; color:#f5f8fa; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
            .fav-image-container { position: relative; width: 100%; }
            .fav-thumb { width: 100%; aspect-ratio:16/9; object-fit: cover; display: block; }
            .fav-btn {
                position: absolute;
                background: rgba(0,0,0,0.32);
                backdrop-filter: blur(4px);
                border: none;
                cursor: pointer;
                font-size: 16px;
                border-radius: 6px;
                padding: 4px;
                color: #f5f8fa;
                width: 36px;
                height: 36px;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: transform 0.18s ease;
            }
            .fav-del { top: 10px; right: 10px; }
            .fav-enlarge { bottom: 10px; right: 10px; font-size:16px; }
            .fav-super { bottom: 10px; left: 10px; font-size:18px; width:36px; height:36px; display:flex; align-items:center; justify-content:center; padding:0; }
            .fav-super span { display:inline-block; width:18px; text-align:center; font-size:16px; transition: transform 0.25s ease, color 0.25s ease; }
            .fav-super span.red { color:#ff4d4d; font-weight:700; transform: rotate(90deg); }
            .fav-date-duration { height:34px; padding:0 12px; font-size:13px; font-weight:300; color:#f5f8fa; display:flex; align-items:center; justify-content:space-between; }
            .fav-tags-container { overflow-x:auto; width:100%; padding:6px 10px; box-sizing:border-box; }
            .fav-tags-container::-webkit-scrollbar { height:6px; }
            .fav-tags-container::-webkit-scrollbar-track { background:#202b33; }
            .fav-tags-container::-webkit-scrollbar-thumb { background:#506070; border-radius:3px; }
            .fav-tags { display:flex; gap:8px; flex-wrap:nowrap; }
            .fav-tag { background: transparent; color: #f5f8fa; padding:1px 5px; border-radius:15px; border:1px solid; display:inline-flex; align-items:center; gap:4px; flex-shrink:0; font-size: 11px; cursor:pointer; }
            .fav-tag .dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex:0 0 8px; }
            #image-overlay { position:fixed; left:0; top:0; width:100%; height:100%; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.85); z-index:99999; }
            #image-overlay img { max-width:90%; max-height:90%; object-fit:contain; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,0.6); }
        `;
        document.head.appendChild(style);
        const superContainer = document.getElementById('super-container');
        const container = document.getElementById('fav-container');
        const searchInput = document.getElementById('search-favs');
        const clearSearch = document.getElementById('clear-search');
        clearSearch.addEventListener('click', ()=>{ searchInput.value=''; renderSuper(); renderFavs(); });

        function sortArray(arr){
            const state = getSortState();
            if(state==='none') return arr.slice().reverse();
            return arr.slice().sort((a,b)=>{
                const dateA = new Date(a.date || 0).getTime();
                const dateB = new Date(b.date || 0).getTime();
                return state==='asc' ? dateA - dateB : dateB - dateA;
            });
        }

        function filterMatch(fav){
            const val = searchInput.value.trim();
            if(!val) return true;
            if(val.toLowerCase().startsWith('tag:')){
                const tags = val.slice(4).split(',').map(t=>t.trim()).filter(Boolean);
                if(tags.length===0) return true;
                return tags.every(t => (fav.tags || []).includes(t));
            } else {
                return fav.title.toLowerCase().includes(val.toLowerCase());
            }
        }

        function createCard(fav, isSuperCard=false){
            const card = document.createElement('div');
            card.className = 'fav-card';
            card.draggable = !isSuperCard;
            const title = document.createElement('div'); title.className = 'fav-title'; title.textContent = fav.title;
            const imgContainer = document.createElement('div'); imgContainer.className = 'fav-image-container';
            const img = document.createElement('img'); img.src = fav.image || ''; img.alt = fav.title; img.className = 'fav-thumb'; img.loading='lazy';
            img.addEventListener('click', ()=>window.open(fav.url, '_blank'));
            const delBtn = document.createElement('button'); delBtn.className = 'fav-btn fav-del'; delBtn.textContent = '💔';
            delBtn.addEventListener('click', e=>{ e.stopPropagation(); removeFavorite(fav.url); card.remove(); renderSuper(); });
            const enlargeBtn = document.createElement('button'); enlargeBtn.className = 'fav-btn fav-enlarge'; enlargeBtn.textContent = '🖼️';
            enlargeBtn.addEventListener('click', e=>{ e.stopPropagation(); const existing = document.getElementById('image-overlay'); if(existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'image-overlay'; const imgEl = document.createElement('img'); imgEl.src = fav.image || ''; overlay.appendChild(imgEl); overlay.addEventListener('click', ev=>{ if(ev.target===overlay) overlay.remove(); }); document.body.appendChild(overlay); });
            const superBtn = document.createElement('button'); superBtn.className = 'fav-btn fav-super';
            const icon = document.createElement('span');
            icon.textContent = isSuper(fav.url) ? '×' : '+';
            if(isSuper(fav.url)) icon.classList.add('red');
            superBtn.appendChild(icon);
            superBtn.addEventListener('click', e => {
                e.stopPropagation();
                if(isSuper(fav.url)){
                    removeSuper(fav.url);
                    icon.textContent = '+';
                    icon.classList.remove('red');
                    icon.style.transform = 'rotate(0deg)';
                } else {
                    addSuper(fav);
                    icon.textContent = '×';
                    icon.classList.add('red');
                    icon.style.transform = 'rotate(90deg)';
                }

                setTimeout(() => {
                    renderSuper();
                    renderFavs();
                }, 250);
            });
            imgContainer.append(img, delBtn, enlargeBtn, superBtn);
            const dateDiv = document.createElement('div'); dateDiv.className = 'fav-date-duration';
            const leftSpan = document.createElement('span'); leftSpan.textContent = fav.date || '';
            const rightSpan = document.createElement('span'); rightSpan.textContent = fav.duration || '';
            dateDiv.append(leftSpan, rightSpan);
            const tagContainerWrapper = document.createElement('div'); tagContainerWrapper.className = 'fav-tags-container';
            const tagContainer = document.createElement('div'); tagContainer.className = 'fav-tags';
            (fav.tags || []).forEach(tagName => {
                if(!tagColorsCache[tagName]) { tagColorsCache[tagName] = randomColor(); }
                const tagEl = document.createElement('div'); tagEl.className = 'fav-tag';
                const dot = document.createElement('span'); dot.className = 'dot'; dot.style.background = tagColorsCache[tagName];
                tagEl.style.borderColor = tagColorsCache[tagName];
                const txt = document.createElement('span'); txt.textContent = tagName;
                tagEl.append(dot, txt);
                tagEl.addEventListener('click', ev=>{
                    ev.stopPropagation();
                    const current = searchInput.value.trim();
                    let tags = [];
                    if(current.toLowerCase().startsWith('tag:')) tags = current.slice(4).split(',').map(s=>s.trim()).filter(Boolean);
                    const idx = tags.indexOf(tagName);
                    if(idx === -1) tags.push(tagName); else tags.splice(idx,1);
                    if(tags.length) searchInput.value = 'tag:' + tags.join(','); else searchInput.value = '';
                    renderSuper(); renderFavs();
                });
                tagContainer.appendChild(tagEl);
            });
            tagContainerWrapper.appendChild(tagContainer);
            card.append(title, imgContainer, dateDiv, tagContainerWrapper);
            card.addEventListener('auxclick', e => {
                if (e.button === 1) {
                    if (!e.target.closest('.fav-tag')) {
                        e.preventDefault();
                        window.open(fav.url, '_blank', 'noopener,noreferrer');
                        setTimeout(()=>{ try{ window.focus(); }catch{} },50);
                    }
                }
            });
            return card;
        }

        function renderList(container, data, isSuper=false){
            container.innerHTML = '';
            const frag = document.createDocumentFragment();
            const list = sortArray(data).filter(filterMatch);
            let index = 0;
            const chunk = 50;
            function renderChunk(){
                const slice = list.slice(index, index+chunk);
                for(const fav of slice){
                    frag.appendChild(createCard(fav, isSuper));
                }
                index += chunk;
                if(index < list.length){
                    if(typeof requestIdleCallback === 'function') requestIdleCallback(renderChunk);
                    else setTimeout(renderChunk, 50);
                } else {
                    container.appendChild(frag);
                    saveTagColors(tagColorsCache);
                }
            }
            renderChunk();
        }

        function renderSuper(){
            const superData = getSuper().filter(f => getFavorites().some(g => g.url === f.url));
            const visible = JSON.parse(localStorage.getItem(SUPER_VISIBLE_KEY) ?? 'true');
            if(visible){
                superContainer.classList.remove('hidden');
                renderList(superContainer, superData, true);
            } else {
                superContainer.classList.add('hidden');
                superContainer.innerHTML = '';
            }
        }

        function renderFavs(){
            const favsData = getFavorites();
            renderList(container, favsData, false);
        }

        document.getElementById('sort-btn').addEventListener('click', ()=>{
            const state = getSortState();
            const next = state==='none' ? 'asc' : state==='asc' ? 'desc' : 'none';
            saveSortState(next);
            document.getElementById('sort-btn').textContent = `Sort: ${next==='none'?'None':next==='asc'?'Oldest':'Newest'}`;
            renderSuper();
            renderFavs();
        });

        document.getElementById('super-toggle-btn').addEventListener('click', ()=>{
            const btn = document.getElementById('super-toggle-btn');
            btn.classList.add('opening');
            setTimeout(()=>btn.classList.remove('opening'), 320);
            const visible = JSON.parse(localStorage.getItem(SUPER_VISIBLE_KEY) ?? 'true');
            localStorage.setItem(SUPER_VISIBLE_KEY, JSON.stringify(!visible));
            renderSuper();
        });

        function debounce(fn, delay=250){
            let t;
            return (...args) => {
                clearTimeout(t);
                t = setTimeout(()=>fn(...args), delay);
            };
        }

        searchInput.addEventListener('input', debounce(()=>{
            renderSuper();
            renderFavs();
        }, 250));

        document.getElementById('export-btn').addEventListener('click', ()=>{
            const now = new Date();
            const name = `StashDB_Favorites_${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}.json`;
            const data = {favorites:getFavorites(), superFavorites:getSuper(), sortState:getSortState(), tagColors:getTagColors()};
            const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = name;
            a.click();
            URL.revokeObjectURL(url);
        });

        document.getElementById('import-btn').addEventListener('click', ()=>{
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.json';
            input.addEventListener('change', e=>{
                const file = e.target.files[0];
                if(!file) return;
                const reader = new FileReader();
                reader.onload = ev=>{
                    try {
                        const data = JSON.parse(ev.target.result);
                        if(data.favorites) saveFavorites(data.favorites);
                        if(data.superFavorites) saveSuper(data.superFavorites);
                        if(data.sortState) saveSortState(data.sortState);
                        if(data.tagColors) saveTagColors(data.tagColors);
                        renderSuper(); renderFavs();
                    } catch {}
                };
                reader.readAsText(file);
            });
            input.click();
        });

        renderSuper();
        renderFavs();
    }

    function init(){
        insertHeaderFavButton();
        insertSceneFavButton();
        if (location.pathname === '/favorites')
            renderFavoritesPage();
    }

    init();
})();