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