StashDB Favorites

Add a new favorite page

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         StashDB Favorites
// @namespace    https://greasyfork.org/fr/users/1468290-payamarre
// @version      1.1
// @author       NoOne
// @description  Add a new favorite page
// @match        https://stashdb.org/*
// @grant        none
// @icon         https://cdn-icons-png.flaticon.com/512/4784/4784090.png
// @license MIT
// ==/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(localStorage.getItem(FAVORITES_KEY) || '[]');
    let superCache = JSON.parse(localStorage.getItem(SUPER_KEY) || '[]');
    let tagColorsCache = JSON.parse(localStorage.getItem(TAG_COLORS_KEY) || '{}');

    const getFavorites = () => favoritesCache;
    const saveFavorites = favs => {
        favoritesCache = favs;
        localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
        syncSuperWithFavorites();
    };
    const getSuper = () => superCache;
    const saveSuper = sup => {
        superCache = sup;
        localStorage.setItem(SUPER_KEY, JSON.stringify(sup));
    };
    const getSortState = () => localStorage.getItem(SORT_STATE_KEY) || 'none';
    const saveSortState = state => localStorage.setItem(SORT_STATE_KEY, state);
    const getTagColors = () => tagColorsCache;
    const saveTagColors = colors => {
        tagColorsCache = colors;
        localStorage.setItem(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 => {
        let favs = getFavorites().filter(f => f.url !== url);
        saveFavorites(favs);
    };

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

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

    function syncSuperWithFavorites() {
        const favs = getFavorites();
        const sup = getSuper();
        const newSup = sup.map(s => {
            const matching = favs.find(f => f.url === s.url);
            return matching ? Object.assign({}, matching) : null;
        }).filter(Boolean);
        saveSuper(newSup);
    }

    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();
})();