Add a new favorite page with persistent syncable storage
// ==UserScript==
// @name StashDB Favorites
// @namespace https://greasyfork.org/fr/users/1468290-payamarre
// @version 1.4
// @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
// @grant GM_addValueChangeListener
// ==/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);
if (typeof GM_addValueChangeListener !== 'undefined') {
GM_addValueChangeListener(FAVORITES_KEY, (name, oldVal, newVal, remote) => {
if (remote) {
favoritesCache = JSON.parse(newVal || '[]');
if (location.pathname === '/favorites' && window.refreshFavorites) window.refreshFavorites();
}
});
GM_addValueChangeListener(SUPER_KEY, (name, oldVal, newVal, remote) => {
if (remote) {
superCache = JSON.parse(newVal || '[]');
if (location.pathname === '/favorites' && window.refreshFavorites) window.refreshFavorites();
}
});
}
const addFavorite = (url, title, image, date, duration, tags, performers) => {
const favs = getFavorites();
if (!favs.some(f => f.url === url)) {
favs.push({ url, title, image, date, duration, tags, performers });
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 h3Span = document.querySelector('.card-header h3 span');
if (!h3Span || 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 performers = Array.from(document.querySelectorAll('.scene-performers a.scene-performer'))
.filter(a => a.querySelector('svg title')?.textContent.trim() === 'Female')
.map(a => ({
name: a.querySelector('span')?.textContent.trim() || a.textContent.trim(),
id: a.href.split('/').pop()
}));
if (isFavorited(location.href)) {
const favs = getFavorites();
const favIndex = favs.findIndex(f => f.url === location.href);
if (favIndex !== -1 && (favs[favIndex].performers || []).length === 0) {
favs[favIndex].performers = performers;
saveFavorites(favs);
}
}
const heartBtn = document.createElement('button');
heartBtn.id = 'fav-btn-scene';
Object.assign(heartBtn.style, {
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '0', margin: '0 10px 0 0', display: 'inline-flex',
alignItems: 'center', fontSize: '1.2em', verticalAlign: 'middle'
});
const icon = document.createElement('i');
const isFav = isFavorited(location.href);
icon.className = isFav ? 'fas fa-heart' : 'far fa-heart';
icon.style.color = '#ff4d4d';
heartBtn.appendChild(icon);
heartBtn.onclick = () => {
const already = isFavorited(location.href);
const title = h3Span.textContent.trim();
const image = document.querySelector('.Image-image')?.src || '';
if (already) {
removeFavorite(location.href);
icon.className = 'far fa-heart';
} else {
addFavorite(location.href, title, image, dateText, durationText, tags, performers);
icon.className = 'fas fa-heart';
}
};
h3Span.prepend(heartBtn);
return true;
};
if (!tryInsert()) {
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (tryInsert() || attempts > 20) clearInterval(interval);
}, 300);
}
}
function insertHeaderFavButton() {
if (document.getElementById('fav-btn-header')) return;
const tryInsert = () => {
const navList = document.querySelector('.navbar-nav');
if (!navList || document.getElementById('fav-btn-header')) return false;
const navItem = document.createElement('li');
navItem.className = 'nav-item';
navItem.id = 'fav-btn-header';
const favLink = document.createElement('a');
favLink.className = 'nav-link';
favLink.href = '/favorites';
favLink.style.display = 'flex';
favLink.style.alignItems = 'center';
favLink.style.gap = '8px';
favLink.innerHTML = '<i class="fas fa-heart" style="color:white; font-size:14px;"></i> Favorites';
navItem.appendChild(favLink);
navList.appendChild(navItem);
return true;
};
if (!tryInsert()) {
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (tryInsert() || attempts > 20) clearInterval(interval);
}, 300);
}
}
(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 insertFavicon() {
const isFavPage = location.pathname === '/favorites';
const color = isFavPage ? '%23ff4d4d' : 'white';
const existing = document.getElementById('stash-fav-icon');
const href = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="${color}" d="M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z"/></svg>`;
if (existing) {
if (existing.getAttribute('data-page') === (isFavPage ? 'fav' : 'stash')) return;
existing.href = href;
existing.setAttribute('data-page', isFavPage ? 'fav' : 'stash');
} else {
document.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
const favicon = document.createElement('link');
favicon.id = 'stash-fav-icon';
favicon.rel = 'icon';
favicon.href = href;
favicon.setAttribute('data-page', isFavPage ? 'fav' : 'stash');
document.head.appendChild(favicon);
}
}
function renderFavoritesPage() {
const startTime = performance.now();
if (location.pathname !== '/favorites') return;
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
window.scrollTo(0, 0);
document.title = 'StashDB Favorites';
if (!document.getElementById('font-awesome-css')) {
const link = document.createElement('link');
link.id = 'font-awesome-css';
link.rel = 'stylesheet';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css';
document.head.appendChild(link);
}
const style = document.createElement('style');
style.textContent = `
:root {
--bg-color: #202b33;
--card-bg: #17171b;
--text-color: #f9f9f9;
--accent-blue: #0063e5;
--header-z: 1000;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
font-family: 'Avenir Next', 'Helvetica Neue', Helvetica, Arial, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.navbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 80px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.9) 0%, transparent 100%);
z-index: var(--header-z);
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
padding: 0 36px;
transition: background 0.3s;
}
.nav-pill {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px;
border-radius: 50px;
gap: 5px;
}
.nav-item {
padding: 10px 18px;
color: #ccc;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1.1px;
transition: all 0.3s ease;
border-radius: 25px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.nav-item:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.nav-item.active {
background: #ffffff;
color: #000000;
}
.nav-search {
display: flex;
align-items: center;
padding: 0 12px;
border-radius: 25px;
height: 36px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
background: transparent;
}
.nav-search:hover, .nav-search:focus-within { background: rgba(255, 255, 255, 0.1); }
.nav-search i { font-size: 16px; color: #ccc; transition: all 0.3s ease; }
.nav-search i.fa-times { color: #fff; }
.nav-search i.fa-times:hover { color: #ccc; transform: scale(1.1); }
.search-input {
background: transparent;
border: none;
color: white;
width: 0;
font-size: 14px;
font-weight: 600;
outline: none;
padding: 0;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-search:focus-within i { color: #fff; }
.nav-search:focus-within .search-input, .nav-search.has-content .search-input {
width: 160px;
padding-left: 10px;
}
.skeleton {
background-color: #1a2228;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-card {
aspect-ratio: 16/11;
width: 100%;
}
.navbar-actions {
display: flex;
gap: 10px;
position: absolute;
right: 40px;
}
.action-btn {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ccc;
min-width: 40px;
height: 40px;
padding: 0 16px;
border-radius: 25px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
}
.action-btn:hover { background: rgba(255, 255, 255, 0.2); color: #fff; transform: scale(1.05); }
.action-btn span {
transition: opacity 0.4s ease, max-width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
max-width: 100px;
opacity: 1;
}
.action-btn.clicked { min-width: 40px; width: 40px; padding: 0; gap: 0; }
.action-btn.clicked span { max-width: 0; opacity: 0; margin: 0; transition: opacity 0.15s ease, max-width 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
.action-btn i { transition: transform 0.3s ease; }
.action-btn.clicked i { transform: scale(1.1); color: #fff; }
.fa-spin { animation: fa-spin 2s infinite linear; }
@keyframes fa-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }
}
.hero-area {
padding-top: 10vh;
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 2rem;
perspective: 1000px;
display: none;
}
.hero-carousel {
position: relative;
width: 95vw;
aspect-ratio: 16/6;
border-radius: 20px;
box-shadow: 0 40px 60px -20px rgba(0,0,0,0.5);
}
.carousel-slide {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0;
transition: opacity 0.8s ease-in-out;
border-radius: 20px;
overflow: hidden;
pointer-events: none;
}
.carousel-slide.active {
opacity: 1;
pointer-events: auto;
border: 1px solid rgba(255,255,255,0.1);
}
.carousel-slide img.bg-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.carousel-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle, transparent 30%, rgba(32, 43, 51, 0.8) 70%, rgba(32, 43, 51, 1) 100%);
}
.carousel-content {
position: absolute;
bottom: 40px; left: 60px;
z-index: 10;
max-width: 50%;
opacity: 0;
transition: opacity 0.8s 0.2s;
}
.carousel-slide.active .carousel-content {
opacity: 1;
}
.carousel-title {
font-size: 2rem;
font-weight: 800;
margin-bottom: 10px;
line-height: 1.1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.carousel-meta {
display: flex;
gap: 5px;
align-items: center;
font-size: 1rem;
color: #ccc;
font-weight: 500;
}
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: white;
width: 50px; height: 50px;
border-radius: 50%;
font-size: 30px;
cursor: pointer;
z-index: 20;
opacity: 0.5;
transition: all 0.3s;
display: flex; align-items: center; justify-content: center;
pointer-events: auto;
text-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.carousel-arrow:hover { opacity: 1 !important; transform: translateY(-50%) scale(1.1); }
.carousel-arrow:active { transform: translateY(-50%) scale(0.9); transition: transform 0.1s; }
.arrow-left { left: 20px; }
.arrow-right { right: 20px; }
.carousel-dots {
position: absolute;
bottom: -40px;
left: 0; right: 0;
display: flex;
justify-content: center;
gap: 10px;
z-index: 20;
}
.dot {
width: 10px; height: 10px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.dot.active {
width: 40px;
border-radius: 10px;
background: rgba(255,255,255,0.2);
}
.dot.active::before {
content: '';
position: absolute;
top: 0; left: 0;
height: 100%;
width: 0;
background: white;
border-radius: 10px;
animation: progressBar 5s linear forwards;
}
@keyframes progressBar {
from { width: 0%; }
to { width: 100%; }
}
.main-container {
padding: 75px 40px 50px;
z-index: 5;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.card {
background: var(--card-bg);
border-radius: 20px;
overflow: hidden;
position: relative;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
aspect-ratio: 16/9;
cursor: pointer;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255,255,255,0.1);
margin: 0;
}
.card:hover {
transform: scale(1.04);
box-shadow: 0 20px 40px rgba(0,0,0,0.5);
border: 3px solid rgba(255,255,255,1);
z-index: 10;
}
.card img.poster-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.card:hover img.poster-img { transform: scale(1.02); }
.card-info {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 60px 15px 15px;
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.6) 50%, rgba(0,0,0,0) 100%);
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 5;
}
.card:hover .card-info { opacity: 1; transform: translateY(0); }
.card-title {
color: #fff;
font-weight: 700;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.card-meta {
display: flex;
justify-content: flex-start;
gap: 2px;
font-size: 11px;
color: #bbb;
font-weight: 500;
align-items: center;
overflow: hidden;
}
.performer-scroll {
display: flex;
flex: 1;
overflow-x: auto;
white-space: nowrap;
gap: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.duration-meta { margin-left: auto; flex-shrink: 0; }
.performer-scroll::-webkit-scrollbar { display: none; }
.performer-link { color: inherit; text-decoration: none; }
.performer-link:hover { text-decoration: underline !important; }
.sep { color: #666; margin: 0 2px; flex-shrink: 0; }
.card-tags {
display: flex;
gap: 2px;
overflow-x: auto;
padding-bottom: 8px;
}
.card-tags::-webkit-scrollbar {
height: 4px;
}
.card-tags::-webkit-scrollbar-track {
background: transparent;
}
.card-tags::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 5px;
border-radius: 20px;
font-size: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.tag:hover { background: rgba(255,255,255,0.15); border-color: #fff !important; }
.tag-dot { width: 8px; height: 8px; border-radius: 50%; }
.card-actions {
position: absolute;
top: 10px; right: 10px;
display: flex;
flex-direction: row;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 20;
}
.card:hover .card-actions { opacity: 1; }
.icon-btn {
background: transparent;
border: none;
color: rgba(255,255,255,0.7);
width: 30px; height: 30px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
transition: all 0.2s;
position: relative;
}
.icon-btn:hover { transform: scale(1.2); filter: drop-shadow(0 0 5px rgba(255,255,255,0.5)); }
.icon-btn[data-action="delete"]:hover { color: #ff4d4d; filter: drop-shadow(0 0 5px rgba(255, 77, 77, 0.5)); }
.icon-btn[data-action="super"]:hover { color: #FFD700; filter: drop-shadow(0 0 5px rgba(255, 215, 0, 0.5)); }
.icon-btn.active { color: #FFD700; filter: drop-shadow(0 0 5px rgba(255, 215, 0, 0.6)); }
.card.removing {
opacity: 0;
transform: scale(0.8);
pointer-events: none;
}
@keyframes mirrorFlip {
0% { transform: scale(1); }
50% { transform: scaleX(-1) scale(1.2); }
100% { transform: scale(1); }
}
.flip-anim { animation: mirrorFlip 0.5s ease-in-out; }
.super-badge {
position: absolute;
top: 10px; left: 10px;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #000;
padding: 4px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 800;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
z-index: 2;
letter-spacing: 0.5px;
cursor: pointer;
transition: transform 0.2s;
}
.page-footer {
padding: 40px;
text-align: center;
color: #555;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
}
.load-time { color: rgba(255,255,255,0.15); }
.page-footer a { color: inherit; text-decoration: none; transition: color 0.3s; }
.page-footer a:hover { color: #fff; text-decoration: underline; }
.navbar-left {
position: absolute;
left: 40px;
display: flex;
gap: 10px;
}
`;
document.head.appendChild(style);
document.body.innerHTML = `
<nav class="navbar">
<div class="navbar-left">
<button id="sync-btn" class="action-btn" title="Sync All Performers"><i class="fas fa-sync"></i> <span>Sync</span></button>
</div>
<div class="nav-pill">
<div id="btn-all" class="nav-item active"><i class="fas fa-layer-group"></i> All</div>
<div id="btn-super" class="nav-item"><i class="fas fa-star"></i> Superstar</div>
<div id="btn-sort" class="nav-item"><i class="fas fa-sort"></i> Sort: None</div>
<div class="nav-search" id="searchWrapper">
<i id="search-icon" class="fas fa-search"></i>
<input type="text" id="search-favs" class="search-input" placeholder="Search...">
</div>
</div>
<div class="navbar-actions">
<button id="import-btn" class="action-btn" title="Import"><i class="fas fa-file-import"></i> <span>Import</span></button>
<button id="export-btn" class="action-btn" title="Export"><i class="fas fa-file-export"></i> <span>Export</span></button>
</div>
</nav>
<div id="hero-area" class="hero-area">
<div class="hero-carousel" id="hero-carousel">
<button class="carousel-arrow arrow-left" id="c-arrow-left"><i class="fas fa-chevron-left"></i></button>
<button class="carousel-arrow arrow-right" id="c-arrow-right"><i class="fas fa-chevron-right"></i></button>
<div id="carousel-slides"></div>
<div class="carousel-dots" id="carousel-dots"></div>
</div>
</div>
<div class="main-container">
<div id="fav-grid" class="grid"></div>
</div>
<footer class="page-footer">
<a href="https://greasyfork.org/en/scripts/551882-stashdb-favorites" target="_blank">STASHDB FAVORITES</a> <span class="load-time" id="load-time-val"></span>
</footer>
<div id="image-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.95); z-index:9999; justify-content:center; align-items:center;">
<img id="overlay-img" style="max-width:90%; max-height:90%; border-radius:8px; box-shadow:0 0 50px rgba(0,0,0,0.7);">
</div>
`;
let viewMode = 'all';
let displayedCount = 24;
let renderTimer;
let isRendering = false;
const PAGE_SIZE = 24;
const grid = document.getElementById('fav-grid');
const searchInput = document.getElementById('search-favs');
const searchIcon = document.getElementById('search-icon');
const searchWrapper = document.getElementById('searchWrapper');
const overlay = document.getElementById('image-overlay');
const overlayImg = document.getElementById('overlay-img');
const heroArea = document.getElementById('hero-area');
const syncBtn = document.getElementById('sync-btn');
syncBtn.onclick = async () => {
const favs = getFavorites();
const toSync = favs.filter(f => !f.performers || f.performers.length === 0);
if (toSync.length === 0) {
alert('All scenes already have performer data!');
return;
}
if (!confirm(`Sync performers for ${toSync.length} scenes? This may take a moment.`)) return;
syncBtn.classList.add('clicked');
const icon = syncBtn.querySelector('i');
icon.className = 'fas fa-sync fa-spin';
let count = 0;
for (const fav of toSync) {
try {
const match = fav.url.match(/\/scenes\/([a-f0-9-]{36})/);
const sceneId = match ? match[1] : null;
if (!sceneId) continue;
const resp = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query FindScene($id: ID!) {
findScene(id: $id) {
performers {
performer {
id
name
gender
}
}
}
}`,
variables: { id: sceneId }
})
});
const json = await resp.json();
const performers = json.data?.findScene?.performers
?.filter(p => p.performer?.gender === 'FEMALE')
.map(p => ({
name: p.performer.name,
id: p.performer.id
})) || [];
if (performers.length > 0) {
fav.performers = performers;
count++;
}
} catch (e) {
console.error('Failed to sync', fav.url, e);
}
}
saveFavorites(favs);
icon.className = 'fas fa-sync';
syncBtn.classList.remove('clicked');
alert(`Synced ${count} scenes!`);
render();
initCarousel();
};
function updateSearchIcon() {
if (searchInput.value.length > 0) {
searchIcon.className = 'fas fa-times';
searchWrapper.classList.add('has-content');
} else {
searchIcon.className = 'fas fa-search';
searchWrapper.classList.remove('has-content');
}
}
searchIcon.onclick = () => {
if (searchIcon.classList.contains('fa-times')) {
searchInput.value = '';
updateSearchIcon();
render();
} else {
searchInput.focus();
}
};
overlay.onclick = (e) => { if (e.target === overlay) overlay.style.display = 'none'; };
let currentSlide = 0;
let slideInterval;
let superFavs = [];
function updateSortBtn() {
const state = getSortState();
let icon = 'fa-sort';
let label = 'None';
if (state === 'asc') { icon = 'fa-sort-up'; label = 'Oldest'; }
else if (state === 'desc') { icon = 'fa-sort-down'; label = 'Newest'; }
document.getElementById('btn-sort').innerHTML = `<i class="fas ${icon}"></i> Sort: ${label}`;
}
function getSearchScore(item, query) {
const title = (item.title || '').toLowerCase();
const performers = (item.performers || []).map(p => (typeof p === 'object' ? p.name : p).toLowerCase()).join(' ');
query = query.toLowerCase();
if (title === query || (performers && performers === query)) return 10000;
if (title.startsWith(query) || (performers && performers.startsWith(query))) return 8000;
if (title.includes(query) || (performers && performers.includes(query))) return 5000;
const words = query.split(/\s+/).filter(w => w.length > 1);
if (words.length > 0) {
let score = 0;
const combined = `${title} ${performers}`;
if (combined.includes(words[0])) score += 2000;
for (let i = 1; i < words.length; i++) {
if (combined.includes(words[i])) score += 500;
}
if (score > 0) return 1000 + score;
}
try {
const fuzzy = query.split('').map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
const regex = new RegExp(fuzzy, 'i');
if (regex.test(title) || regex.test(performers)) return 100;
} catch (e) { }
return 0;
}
function getFilteredData() {
let baseData = viewMode === 'super' ? getSuper() : getFavorites();
let data = baseData.map((item, originalIndex) => ({ item, originalIndex }));
const query = searchInput.value.trim().toLowerCase();
if (query) {
if (query.includes('tag:')) {
const tagTerms = query.match(/tag:"([^"]+)"|tag:([^\s]+)/g) || [];
const otherTerms = query.replace(/tag:"([^"]+)"|tag:([^\s]+)/g, '').trim();
const tags = tagTerms.map(t => t.startsWith('tag:"') ? t.slice(5, -1) : t.slice(4)).filter(Boolean).map(t => t.toLowerCase());
if (tags.length) {
data = data.filter(entry => tags.every(t => (entry.item.tags || []).some(ft => ft.toLowerCase() === t)));
}
if (otherTerms) {
data = data.map(entry => ({ ...entry, score: getSearchScore(entry.item, otherTerms) }))
.filter(entry => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.originalIndex - a.originalIndex;
});
} else {
data = data.sort((a, b) => b.originalIndex - a.originalIndex);
}
} else {
data = data.map(entry => ({ ...entry, score: getSearchScore(entry.item, query) }))
.filter(entry => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.originalIndex - a.originalIndex;
});
}
return data.map(entry => entry.item);
}
const state = getSortState();
if (state !== 'none') {
baseData = baseData.slice().sort((a, b) => {
const da = new Date(a.date || 0).getTime();
const db = new Date(b.date || 0).getTime();
return state === 'asc' ? da - db : db - da;
});
} else {
baseData = baseData.slice().reverse();
}
return baseData;
}
function createCard(fav) {
const el = document.createElement('div');
el.className = 'card';
const isSup = isSuper(fav.url);
let tagsHtml = '';
if (fav.tags && fav.tags.length) {
tagsHtml = '<div class="card-tags">' + fav.tags.map(t => {
const color = tagColorsCache[t] || randomColor();
if (!tagColorsCache[t]) { tagColorsCache[t] = color; saveTagColors(tagColorsCache); }
return `<span class="tag" data-tag="${t}" style="border-color:${color}"><span class="tag-dot" style="background:${color}"></span>${t}</span>`;
}).join('') + '</div>';
}
el.innerHTML = `
<img class="poster-img" src="${fav.image || ''}" loading="lazy">
${isSup ? '<div class="super-badge">SUPER</div>' : ''}
<div class="card-actions">
<button class="icon-btn ${isSup ? 'active' : ''}" title="Toggle Super" data-action="super"><i class="fas fa-star"></i></button>
<button class="icon-btn" title="Delete" data-action="delete"><i class="fas fa-trash"></i></button>
</div>
<div class="card-info">
<div class="card-title">${fav.title}</div>
<div class="card-meta">
<span>${fav.date || ''}</span>
${fav.performers && fav.performers.length ? `
<span class="sep">•</span>
<div class="performer-scroll">${fav.performers.map(p => {
const name = typeof p === 'object' ? p.name : p;
const id = typeof p === 'object' ? p.id : null;
const url = id ? `https://stashdb.org/performers/${id}` : `https://stashdb.org/search/%22${encodeURIComponent(name)}%22`;
return `<a href="${url}" target="_blank" class="performer-link" onclick="event.stopPropagation();">${name}</a>`;
}).join('<span style="color:#666">,</span> ')}</div>` : ''}
<span class="duration-meta">${fav.duration || ''}</span>
</div>
${tagsHtml}
</div>
`;
el.onclick = (e) => {
if (e.target.closest('.card-actions') || e.target.closest('.tag') || e.target.closest('.super-badge')) return;
window.open(fav.url, '_blank');
};
const onBadgeClick = (e) => {
e.stopPropagation();
viewMode = 'super';
document.getElementById('btn-super').classList.add('active');
document.getElementById('btn-all').classList.remove('active');
window.scrollTo(0, 0);
render(false, true);
};
const badge = el.querySelector('.super-badge');
if (badge) badge.onclick = onBadgeClick;
el.querySelector('.card-actions').onclick = (e) => {
e.stopPropagation();
const btn = e.target.closest('button');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'super') {
btn.classList.remove('flip-anim');
void btn.offsetWidth;
btn.classList.add('flip-anim');
const isSup = isSuper(fav.url);
if (isSup) {
removeSuper(fav.url);
btn.classList.remove('active');
el.querySelector('.super-badge')?.remove();
} else {
addSuper(fav);
btn.classList.add('active');
if (!el.querySelector('.super-badge')) {
const badge = document.createElement('div');
badge.className = 'super-badge';
badge.textContent = 'SUPER';
badge.onclick = onBadgeClick;
el.prepend(badge);
}
}
setTimeout(() => {
btn.classList.remove('flip-anim');
if (viewMode === 'super' && isSup) {
el.classList.add('removing');
setTimeout(() => {
el.remove();
initCarousel();
}, 300);
} else if (viewMode === 'super' && !isSup) {
render();
initCarousel();
} else {
initCarousel();
}
}, 500);
} else if (action === 'delete') {
if (confirm('Remove from favorites?')) {
el.classList.add('removing');
setTimeout(() => {
removeFavorite(fav.url);
el.remove();
initCarousel();
}, 300);
}
}
};
el.querySelectorAll('.tag').forEach(tagEl => {
tagEl.onclick = (e) => {
e.stopPropagation();
const t = tagEl.dataset.tag;
let val = searchInput.value.trim();
const tagStr = `tag:"${t}"`;
if (val.includes(tagStr)) {
const escaped = tagStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('(^|\\s)' + escaped + '(\\s|$)', 'g');
val = val.replace(re, ' ').trim();
searchInput.value = val;
} else {
searchInput.value = val ? val + ' ' + tagStr : tagStr;
}
if (searchInput.value.length > 0) searchWrapper.classList.add('has-content');
else searchWrapper.classList.remove('has-content');
updateSearchIcon();
displayedCount = PAGE_SIZE;
window.scrollTo(0, 0);
render(false, true);
};
});
return el;
}
function render(append = false, showSkeletons = false) {
if (isRendering && append) return;
isRendering = true;
clearTimeout(renderTimer);
const data = getFilteredData();
if (!append) {
window.scrollTo(0, 0);
if (showSkeletons) {
grid.innerHTML = '';
for (let i = 0; i < 9; i++) {
const skel = document.createElement('div');
skel.className = 'skeleton skeleton-card';
grid.appendChild(skel);
}
renderTimer = setTimeout(() => {
grid.innerHTML = '';
const subset = data.slice(0, displayedCount);
const frag = document.createDocumentFragment();
subset.forEach(item => frag.appendChild(createCard(item)));
grid.appendChild(frag);
window.scrollTo(0, 0);
grid.style.minHeight = '';
setTimeout(() => { isRendering = false; }, 100);
}, 300);
} else {
const subset = data.slice(0, displayedCount);
grid.innerHTML = '';
const frag = document.createDocumentFragment();
subset.forEach(item => frag.appendChild(createCard(item)));
grid.appendChild(frag);
isRendering = false;
}
} else {
const start = grid.children.length;
const subset = data.slice(start, start + PAGE_SIZE);
const frag = document.createDocumentFragment();
subset.forEach(item => frag.appendChild(createCard(item)));
grid.appendChild(frag);
displayedCount = grid.children.length;
isRendering = false;
}
}
function initCarousel() {
const prevUrl = superFavs[currentSlide]?.url;
superFavs = getSuper().slice().reverse();
if (superFavs.length === 0) {
heroArea.style.display = 'none';
return;
}
heroArea.style.display = 'flex';
const slidesContainer = document.getElementById('carousel-slides');
const dotsContainer = document.getElementById('carousel-dots');
slidesContainer.innerHTML = superFavs.map((item, i) => `
<div class="carousel-slide" data-index="${i}" onclick="window.open('${item.url}', '_blank')" style="cursor: pointer;">
<img class="bg-img" src="${item.image}">
<div class="carousel-overlay"></div>
<div class="carousel-content">
<div class="carousel-title">${item.title}</div>
<div class="carousel-meta">
${item.date ? `<span>${item.date}</span>` : ''}
${item.date && (item.performers?.length) ? '<span class="sep">•</span>' : ''}
${item.performers && item.performers.length ? `
<div class="performer-scroll">${item.performers.map(p => {
const name = typeof p === 'object' ? p.name : p;
const id = typeof p === 'object' ? p.id : null;
const url = id ? `https://stashdb.org/performers/${id}` : `https://stashdb.org/search/%22${encodeURIComponent(name)}%22`;
return `<a href="${url}" target="_blank" class="performer-link" onclick="event.stopPropagation();">${name}</a>`;
}).join('<span style="color:#666">,</span> ')}</div>` : ''}
<span class="duration-meta">${item.duration || ''}</span>
</div>
</div>
</div>
`).join('');
dotsContainer.innerHTML = superFavs.map((_, i) => `
<div class="dot" data-index="${i}"></div>
`).join('');
dotsContainer.querySelectorAll('.dot').forEach(dot => {
dot.onclick = (e) => {
e.stopPropagation();
window.setSlide(parseInt(dot.dataset.index));
};
});
const newIndex = superFavs.findIndex(f => f.url === prevUrl);
currentSlide = newIndex >= 0 ? newIndex : 0;
const slides = slidesContainer.querySelectorAll('.carousel-slide');
const dots = dotsContainer.querySelectorAll('.dot');
slides.forEach((s, i) => s.classList.toggle('active', i === currentSlide));
dots.forEach((d, i) => d.classList.toggle('active', i === currentSlide));
resetTimer();
}
window.refreshFavorites = () => {
render();
initCarousel();
};
window.setSlide = function (index) {
const slides = document.querySelectorAll('.carousel-slide');
const dots = document.querySelectorAll('.dot');
if (!slides.length) return;
if (index >= slides.length) index = 0;
if (index < 0) index = slides.length - 1;
slides[currentSlide].classList.remove('active');
dots[currentSlide].classList.remove('active');
currentSlide = index;
slides[currentSlide].classList.add('active');
dots[currentSlide].classList.add('active');
resetTimer();
}
function resetTimer() {
clearInterval(slideInterval);
slideInterval = setInterval(() => {
window.setSlide(currentSlide + 1);
}, 5000);
const activeDot = document.querySelector('.dot.active');
if (activeDot) {
activeDot.classList.remove('active');
void activeDot.offsetWidth;
activeDot.classList.add('active');
}
}
document.getElementById('c-arrow-left').onclick = () => window.setSlide(currentSlide - 1);
document.getElementById('c-arrow-right').onclick = () => window.setSlide(currentSlide + 1);
document.getElementById('btn-all').onclick = () => {
viewMode = 'all';
document.getElementById('btn-all').classList.add('active');
document.getElementById('btn-super').classList.remove('active');
render();
};
document.getElementById('btn-super').onclick = () => {
viewMode = 'super';
document.getElementById('btn-super').classList.add('active');
document.getElementById('btn-all').classList.remove('active');
render();
};
document.getElementById('btn-sort').onclick = () => {
const state = getSortState();
const next = state === 'none' ? 'asc' : state === 'asc' ? 'desc' : 'none';
saveSortState(next);
updateSortBtn();
render();
};
document.addEventListener('keydown', (e) => {
if (location.pathname !== '/favorites' || e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === 'Escape') {
searchInput.value = '';
searchInput.blur();
updateSearchIcon();
render();
return;
}
if (e.key.length !== 1 || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
searchInput.focus();
});
searchInput.oninput = () => {
updateSearchIcon();
displayedCount = PAGE_SIZE;
window.scrollTo(0, 0);
render(false, true);
};
window.onscroll = () => {
if (isRendering) return;
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 600) {
const total = getFilteredData().length;
if (grid.children.length < total) {
render(true);
}
}
};
document.getElementById('export-btn').onclick = () => {
const btn = document.getElementById('export-btn');
const icon = btn.querySelector('i');
btn.classList.add('clicked');
icon.className = 'fas fa-check';
const now = new Date();
const name = `StashDB_Favorites_${now.toISOString().slice(0, 10)}.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);
setTimeout(() => {
btn.classList.remove('clicked');
setTimeout(() => { icon.className = 'fas fa-file-export'; }, 300);
}, 2500);
};
document.getElementById('import-btn').onclick = () => {
const btn = document.getElementById('import-btn');
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
btn.classList.add('clicked');
const reader = new FileReader();
reader.onload = ev => {
try {
const d = JSON.parse(ev.target.result);
if (d.favorites) saveFavorites(d.favorites);
if (d.superFavorites) saveSuper(d.superFavorites);
render();
initCarousel();
} catch { }
setTimeout(() => btn.classList.remove('clicked'), 1000);
};
reader.readAsText(file);
};
input.click();
};
updateSortBtn();
render(false, true);
initCarousel();
const loadTime = (performance.now() - startTime).toFixed(0);
const timeVal = document.getElementById('load-time-val');
if (timeVal) timeVal.textContent = `• LOADED IN ${loadTime}MS`;
}
function init() {
if (!document.getElementById('font-awesome-global')) {
const link = document.createElement('link');
link.id = 'font-awesome-global';
link.rel = 'stylesheet';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css';
document.head.appendChild(link);
}
insertFavicon();
insertHeaderFavButton();
insertSceneFavButton();
if (location.pathname === '/favorites')
renderFavoritesPage();
}
init();
})();