Adds quick-search chips for Z-Lib, Anna's Archive, Gutenberg, and AudiobookBay on Goodreads, StoryGraph, and Hardcover book pages
// ==UserScript==
// @name GoodLib
// @namespace https://greasyfork.org/users/KosherKale
// @version 1.3.1
// @icon https://i.imgur.com/WpdBKjf.png
// @description Adds quick-search chips for Z-Lib, Anna's Archive, Gutenberg, and AudiobookBay on Goodreads, StoryGraph, and Hardcover book pages
// @author kosherkale
// @match https://www.goodreads.com/book/*
// @match https://app.thestorygraph.com/*
// @match https://hardcover.app/*
// @license MIT
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.openInTab
// @grant GM.addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict'
const SOURCES = [
{
key: 'zlib',
chipLabel: 'Z-Lib', chipGlyph: 'z',
popupAvatar: 'Z', popupName: 'Z-Lib',
enabledKey: 'zlibEnabled', mirrorKey: 'zlibMirror',
mirrors: ['z-lib.gl', 'z-lib.id', 'z-lib.fm', 'singlelogin.re']
},
{
key: 'anna',
chipLabel: "Anna's", chipGlyph: 'A',
popupAvatar: 'A', popupName: "Anna's",
enabledKey: 'annaEnabled', mirrorKey: 'annaMirror',
mirrors: ['annas-archive.gd', 'annas-archive.se', 'annas-archive.li']
},
{
key: 'libgen',
chipLabel: 'LibGen', chipGlyph: 'LG',
popupAvatar: 'LG', popupName: 'LibGen',
enabledKey: 'libgenEnabled', mirrorKey: 'libgenMirror',
mirrors: ['libgen.li', 'libgen.rs', 'libgen.st']
},
{
key: 'gutenberg',
chipLabel: 'Gutenberg', chipGlyph: 'PG',
popupAvatar: 'PG', popupName: 'Project Gutenberg',
enabledKey: 'gutenbergEnabled', mirrorKey: 'gutenbergMirror',
mirrors: ['gutenberg.org']
},
{
key: 'audiobook',
chipLabel: 'AudiobookBay', chipGlyph: 'AB',
popupAvatar: 'AB', popupName: 'AudiobookBay',
enabledKey: 'audiobookEnabled', mirrorKey: 'audiobookMirror',
mirrors: ['audiobookbay.lu', 'audiobookbay.is'],
titleOnly: true
}
];
// Derived lookups all keyed by source key string
const SOURCE_MAP = Object.fromEntries(SOURCES.map(s => [s.key, s]));
const SOURCE_ORDER = SOURCES.map(s => s.key);
const ENABLED_KEYS = Object.fromEntries(SOURCES.map(s => [s.key, s.enabledKey]));
const MIRROR_KEYS = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrorKey]));
const MIRRORS = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrors]));
// Mutable state declared before async calls
const selectedMirror = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrors[0]]));
const enabledBySource = Object.fromEntries(SOURCES.map(s => [s.key, true]));
const lastStored = Object.fromEntries(SOURCES.map(s => [s.key, null]));
// CSS
const css = `
.goodlib-settings-button{display:inline-flex;align-items:center;justify-content:center;height:34px;width:34px;border-radius:50%;border:1px solid rgba(0,0,0,0.08);background:transparent;margin-left:8px;cursor:pointer;padding:4px}
.goodlib-settings-button:hover{box-shadow:0 6px 18px rgba(0,0,0,0.08);transform:rotate(10deg)}
.goodlib-settings-button--storygraph{height:auto;width:auto;border-radius:0;border:none;border-bottom:4px solid transparent;padding:0 4px;padding-top:4px;margin-left:0;font-size:0.75rem;font-weight:600;color:inherit}
.goodlib-settings-button--storygraph:hover{box-shadow:none;transform:none;border-bottom-color:#0d7377}
html.dark .goodlib-settings-button--storygraph:hover{border-bottom-color:#06b6d4}
.goodlib-settings-button--hardcover{display:inline-flex;align-items:center;gap:0.5rem;height:auto;width:auto;border-radius:0.75rem;border:none;padding:0.5rem 1rem;background:rgba(209,213,219,0.3);color:inherit;font-size:0.875rem;font-weight:600;margin:0.5rem 0;cursor:pointer;backdrop-filter:blur(4px);transition:background 0.15s}
.goodlib-settings-button--hardcover:hover{box-shadow:none;transform:none;background:rgba(209,213,219,0.5)}
html.dark .goodlib-settings-button--hardcover{background:rgba(17,24,39,0.3)}
html.dark .goodlib-settings-button--hardcover:hover{background:rgba(17,24,39,0.5)}
.goodlib-chevron{transition:transform 0.15s ease-out;display:inline}
.goodlib-settings-button--hardcover.goodlib-open .goodlib-chevron{transform:rotate(180deg)}
.goodlib-settings-popup{position:absolute;z-index:100000;background:linear-gradient(180deg,#fff,#fbfdff);border:1px solid rgba(0,0,0,0.06);padding:10px;border-radius:10px;min-width:260px;box-shadow:0 12px 36px rgba(16,20,24,0.12);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
.goodlib-card-header{display:flex;align-items:center;justify-content:space-between;padding:4px 6px 8px;border-bottom:1px solid rgba(0,0,0,0.04);margin-bottom:6px}
.goodlib-card-title{font-weight:800;font-size:13px;color:#1e1e1e}
.goodlib-row{display:flex;align-items:center;gap:10px;padding:7px 6px;border-radius:8px}
.goodlib-row + .goodlib-row{border-top:1px solid rgba(0,0,0,0.04)}
.goodlib-avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:12px}
.goodlib-avatar.zlib{background:#3b82f6}
.goodlib-avatar.anna{background:#00d1b2}
.goodlib-avatar.gutenberg{background:#ffb703;color:#111}
.goodlib-avatar.audiobook{background:#6f42c1}
.goodlib-avatar.libgen{background:#e63946}
.goodlib-meta{flex:1;min-width:0}
.goodlib-name{font-weight:700;font-size:13px;color:#111}
.goodlib-domain{font-size:11px;color:#8b95a1;margin-top:2px}
.goodlib-toggle{display:inline-flex;align-items:center}
.goodlib-toggle input{display:none}
.goodlib-toggle .slider{width:40px;height:22px;border-radius:20px;background:#e6eef9;position:relative;transition:all 0.2s}
.goodlib-toggle .slider:before{content:'';position:absolute;left:2px;top:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,0.12);transition:all 0.2s}
.goodlib-toggle input:checked + .slider{background:#cfe8ff}
.goodlib-toggle input:checked + .slider:before{transform:translateX(18px)}
.goodlib-toggle[data-source="zlib"] input:checked + .slider{background:#3b82f6}
.goodlib-toggle[data-source="anna"] input:checked + .slider{background:#00d1b2}
.goodlib-toggle[data-source="gutenberg"] input:checked + .slider{background:#ffb703}
.goodlib-toggle[data-source="audiobook"] input:checked + .slider{background:#6f42c1}
.goodlib-toggle[data-source="libgen"] input:checked + .slider{background:#e63946}
.goodlib-domain-select{font-size:11px;color:#8b95a1;margin-top:2px;border:none;background:transparent;cursor:pointer;padding:0;-webkit-appearance:none;appearance:none;font-family:inherit;outline:none;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;display:block}
.goodlib-domain-select:hover{color:#4b86d8}
.goodlib-chip-wrap{display:inline-flex;align-items:center;gap:10px;margin-left:10px}
.goodlib-chip{display:inline-flex;align-items:center;height:28px;padding:0 12px 0 8px;border:1.5px solid rgba(0,0,0,0.2);border-radius:14px;background:transparent;text-decoration:none;color:inherit;font-size:11px;font-weight:700;line-height:1;vertical-align:middle;cursor:pointer;white-space:nowrap;position:relative;z-index:10;pointer-events:auto;transition:transform 0.25s cubic-bezier(0.175,0.885,0.32,1.275),border-color 0.25s cubic-bezier(0.175,0.885,0.32,1.275),box-shadow 0.25s cubic-bezier(0.175,0.885,0.32,1.275);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;transform:scale(1) translateY(0)}
[data-goodlib-site="goodreads"] .goodlib-chip{border:1px solid rgba(0,0,0,0.06);background:rgba(255,255,255,0.85);backdrop-filter:blur(8px);color:rgb(51,51,51);box-shadow:0 1px 2px rgba(0,0,0,0.04)}
[data-goodlib-site="goodreads"] .goodlib-chip:hover{background:rgba(255,255,255,0.96);border-color:rgba(0,0,0,0.15);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
[data-goodlib-site="goodreads"] .goodlib-chip-label{color:rgb(51,51,51)}
[data-goodlib-site="storygraph"] .goodlib-chip--zlib{border-color:#3b82f6}
[data-goodlib-site="storygraph"] .goodlib-chip--anna{border-color:#00d1b2}
[data-goodlib-site="storygraph"] .goodlib-chip--gutenberg{border-color:#ffb703}
[data-goodlib-site="storygraph"] .goodlib-chip--audiobook{border-color:#6f42c1}
[data-goodlib-site="storygraph"] .goodlib-chip--libgen{border-color:#e63946}
[data-goodlib-site="storygraph"] .goodlib-chip:hover{background:rgba(128,128,128,0.07);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
.goodlib-chip-icon{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:#3b82f6;margin-right:8px;font-size:9px;font-weight:800;color:#fff;flex-shrink:0;line-height:1}
.goodlib-chip--anna .goodlib-chip-icon{background:#00d1b2}
.goodlib-chip--gutenberg .goodlib-chip-icon{background:#ffdd57;color:#333}
.goodlib-chip--libgen .goodlib-chip-icon{background:#e63946}
.goodlib-chip--audiobook .goodlib-chip-icon{background:#6f42c1}
.goodlib-chip-glyph{font-size:9px;font-weight:800;line-height:1;transform:translateY(-1px) rotate(0deg);transition:transform 0.3s}
.goodlib-chip-glyph--wide{font-size:8px;transform:translateY(-0.5px) rotate(0deg)}
.goodlib-chip:hover .goodlib-chip-glyph{transform:translateY(-1px) rotate(15deg)}
.goodlib-chip:hover .goodlib-chip-glyph--wide{transform:translateY(-0.5px) rotate(15deg)}
.goodlib-chip-label{font-size:11px;font-weight:700;color:inherit;line-height:1;letter-spacing:-0.2px}
.goodlib-chip-wrap--block{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}
html.dark .goodlib-settings-button--goodreads{background:#252535;border-color:rgba(255,255,255,0.1)}
html.dark .goodlib-settings-button--goodreads svg path{fill:#bbb !important}
[data-goodlib-site="hardcover"] .goodlib-chip--zlib{border-color:#3b82f6}
[data-goodlib-site="hardcover"] .goodlib-chip--anna{border-color:#00d1b2}
[data-goodlib-site="hardcover"] .goodlib-chip--gutenberg{border-color:#ffb703}
[data-goodlib-site="hardcover"] .goodlib-chip--audiobook{border-color:#6f42c1}
[data-goodlib-site="hardcover"] .goodlib-chip--libgen{border-color:#e63946}
[data-goodlib-site="hardcover"] .goodlib-chip:hover{background:rgba(128,128,128,0.07);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
.goodlib-settings-popup.goodlib-dark{border-color:rgba(255,255,255,0.08)}
.goodlib-settings-popup.goodlib-dark .goodlib-card-title{color:#eaeaea}
.goodlib-settings-popup.goodlib-dark .goodlib-card-header{border-bottom-color:rgba(255,255,255,0.06)}
.goodlib-settings-popup.goodlib-dark .goodlib-row + .goodlib-row{border-top-color:rgba(255,255,255,0.06)}
.goodlib-settings-popup.goodlib-dark .goodlib-name{color:#e0e0e0}
.goodlib-settings-popup.goodlib-dark .goodlib-domain{color:#6e7a8a}
.goodlib-settings-popup.goodlib-dark .goodlib-toggle .slider{background:#363650}
.goodlib-settings-popup.goodlib-dark .goodlib-domain-select{color:#6e7a8a}
.goodlib-settings-popup.goodlib-dark .goodlib-domain-select:hover{color:#7aa6d8}
/* ── Hardcover-native popup style ───────────────────────────────── */
.goodlib-popup--hardcover{background:#fff;border:none;outline:2px solid #e5e7eb;padding:6px 0;border-radius:0.5rem;box-shadow:0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05);min-width:300px}
.goodlib-popup--hardcover .goodlib-card-header{padding:8px 12px 6px;border-bottom:1px solid #f3f4f6;margin-bottom:2px}
.goodlib-popup--hardcover .goodlib-card-title{color:#111827}
.goodlib-popup--hardcover .goodlib-row{padding:6px 8px;margin:0 4px;border-radius:8px;border-top:none;transition:background 0.15s}
.goodlib-popup--hardcover .goodlib-row + .goodlib-row{border-top:none}
.goodlib-popup--hardcover .goodlib-row:hover{background:#f3f4f6}
.goodlib-popup--hardcover .goodlib-avatar{width:40px;height:40px;border-radius:8px;background:#f3f4f6;transition:background 0.15s,color 0.15s}
.goodlib-popup--hardcover .goodlib-avatar.zlib{color:#3b82f6}
.goodlib-popup--hardcover .goodlib-avatar.anna{color:#00d1b2}
.goodlib-popup--hardcover .goodlib-avatar.gutenberg{color:#d97706;background:#f3f4f6}
.goodlib-popup--hardcover .goodlib-avatar.audiobook{color:#6f42c1}
.goodlib-popup--hardcover .goodlib-avatar.libgen{color:#e63946}
.goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.zlib{background:#3b82f6;color:#fff}
.goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.anna{background:#00d1b2;color:#fff}
.goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.gutenberg{background:#d97706;color:#fff}
.goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.audiobook{background:#6f42c1;color:#fff}
.goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.libgen{background:#e63946;color:#fff}
.goodlib-popup--hardcover .goodlib-name{color:#1f2937;font-size:0.875rem}
.goodlib-popup--hardcover .goodlib-domain,.goodlib-popup--hardcover .goodlib-domain-select{color:#4b5563;font-size:0.75rem}
.goodlib-popup--hardcover .goodlib-domain-select:hover{color:#3b82f6}
.goodlib-popup-nip{position:absolute;top:-10px;width:20px;height:10px;pointer-events:none}
.goodlib-popup-nip::before{content:'';position:absolute;bottom:0;left:0;width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-bottom:10px solid #e5e7eb}
.goodlib-popup-nip::after{content:'';position:absolute;bottom:0;left:2px;width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:8px solid #fff}
/* Hardcover dark */
.goodlib-popup--hardcover.goodlib-dark{background:#111827;outline-color:#374151}
.goodlib-popup--hardcover.goodlib-dark .goodlib-card-header{border-bottom-color:#1f2937}
.goodlib-popup--hardcover.goodlib-dark .goodlib-card-title{color:#e5e7eb}
.goodlib-popup--hardcover.goodlib-dark .goodlib-row:hover{background:#1f2937}
.goodlib-popup--hardcover.goodlib-dark .goodlib-avatar{background:#1f2937}
.goodlib-popup--hardcover.goodlib-dark .goodlib-name{color:#e5e7eb}
.goodlib-popup--hardcover.goodlib-dark .goodlib-domain,.goodlib-popup--hardcover.goodlib-dark .goodlib-domain-select{color:#9ca3af}
.goodlib-popup--hardcover.goodlib-dark .goodlib-domain-select:hover{color:#60a5fa}
.goodlib-popup--hardcover.goodlib-dark .goodlib-footer{border-top-color:#1f2937}
.goodlib-popup--hardcover.goodlib-dark .goodlib-footer-label{color:#9ca3af}
.goodlib-popup--hardcover.goodlib-dark .goodlib-lang-select{background:#1f2937;border:none;color:#d1d5db}
.goodlib-popup--hardcover.goodlib-dark .goodlib-toggle .slider{background:#374151}
.goodlib-popup--hardcover.goodlib-dark .goodlib-popup-nip::before{border-bottom-color:#374151}
.goodlib-popup--hardcover.goodlib-dark .goodlib-popup-nip::after{border-bottom-color:#111827}
`;
if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') {
GM.addStyle(css);
} else {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
const SITE = location.hostname.includes('thestorygraph') ? 'storygraph'
: location.hostname.includes('hardcover') ? 'hardcover'
: 'goodreads';
document.documentElement.setAttribute('data-goodlib-site', SITE);
const CHIP_ATTR = 'data-goodlib-chip';
const CHIP_CLASS = 'goodlib-chip';
const CHIPS_WRAP_ATTR = 'data-goodlib-chip-wrap';
const titleSelectors = SITE === 'storygraph'
? ['h3[class*="font-bold"]', 'h3[class*="font-body"]', 'h3']
: SITE === 'hardcover'
? ['h1.font-serif', 'h1[class*="font-serif"]', 'h1']
: ["h1[data-testid='bookTitle']", 'h1.Text__title1', 'h1'];
const authorSelectors = SITE === 'storygraph' || SITE === 'hardcover'
? ['a[href*="/authors/"]']
: ["a[data-testid='name']", "[data-testid='authorName']", ".ContributorLinksList a.ContributorLink", 'a.ContributorLink', 'span.ContributorLink__name'];
function getBookTitle() {
for (const selector of titleSelectors) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement && node.innerText.trim().length > 0) return node;
}
return null;
}
function normalizeText(value) {
return value.replace(/\s+/g, ' ').trim();
}
function getCleanBookTitle(title) {
const clone = title.cloneNode(true);
const injectedWrap = clone.querySelector('[' + CHIPS_WRAP_ATTR + ']');
if (injectedWrap) injectedWrap.remove();
return normalizeText(clone.textContent || '');
}
function getPrimaryAuthor() {
for (const selector of authorSelectors) {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) continue;
const text = normalizeText(node.innerText);
if (text.length > 0) return text;
}
return '';
}
function buildSearchQuery(title, author) {
return [title, author].filter(Boolean).join(' ');
}
function removeChip() {
document.querySelectorAll('[' + CHIP_ATTR + ']').forEach(chip => chip.remove());
const wrap = document.querySelector('[' + CHIPS_WRAP_ATTR + ']');
if (wrap && wrap.childElementCount === 0) wrap.remove();
}
function buildSourceUrl(source, query) {
const qLower = String(query).toLowerCase();
const encoded = encodeURIComponent(qLower);
const mirror = selectedMirror[source] || MIRRORS[source][0];
if (source === 'anna') return `https://${mirror}/search?q=${encoded}`;
if (source === 'gutenberg') return `https://www.${mirror}/ebooks/search/?query=${encoded}`;
if (source === 'libgen') return `https://${mirror}/index.php?req=${encoded}&lg_topic=libgen&open=0&view=simple&res=25&phrase=1&column=def`;
if (source === 'audiobook') {
const encodedPlus = encoded.replace(/%20/g, '+');
return `https://${mirror}/?s=${encodedPlus}&cat=undefined%2Cundefined`;
}
return `https://${mirror}/s/${encoded}`;
}
function makeChip(source, searchQuery) {
const src = SOURCE_MAP[source];
const chip = document.createElement('span');
chip.setAttribute(CHIP_ATTR, source);
chip.className = `${CHIP_CLASS} ${CHIP_CLASS}--${source}`;
chip.setAttribute('data-search-query', searchQuery);
const glyphClass = src.chipGlyph.length > 1
? 'goodlib-chip-glyph goodlib-chip-glyph--wide'
: 'goodlib-chip-glyph';
const icon = document.createElement('span');
icon.className = 'goodlib-chip-icon';
const glyphNode = document.createElement('span');
glyphNode.className = glyphClass;
glyphNode.textContent = src.chipGlyph;
icon.appendChild(glyphNode);
const label = document.createElement('span');
label.className = 'goodlib-chip-label';
label.textContent = src.chipLabel;
chip.replaceChildren(icon, label);
chip.addEventListener('click', () => {
const query = chip.getAttribute('data-search-query') || searchQuery;
window.open(buildSourceUrl(source, query), '_blank', 'noopener,noreferrer');
});
return chip;
}
function injectChips(enabledBySource) {
const title = getBookTitle();
if (!title) return;
const bookTitle = getCleanBookTitle(title);
if (!bookTitle) return;
const primaryAuthor = getPrimaryAuthor();
const searchQuery = buildSearchQuery(bookTitle, primaryAuthor);
let wrap;
if (SITE === 'storygraph' || SITE === 'hardcover') {
const anchor = SITE === 'storygraph'
? document.querySelector('p.text-sm.font-light.text-darkestGrey')
: document.querySelector('div.mt-2.lg\\:mt-0');
if (!anchor) return;
const next = anchor.nextElementSibling;
if (next instanceof HTMLElement && next.hasAttribute(CHIPS_WRAP_ATTR)) {
wrap = next;
} else {
wrap = document.createElement('div');
wrap.setAttribute(CHIPS_WRAP_ATTR, 'true');
wrap.className = 'goodlib-chip-wrap goodlib-chip-wrap--block';
anchor.parentNode.insertBefore(wrap, anchor.nextSibling);
}
} else {
wrap = title.querySelector('[' + CHIPS_WRAP_ATTR + ']');
if (!(wrap instanceof HTMLElement)) {
wrap = document.createElement('span');
wrap.setAttribute(CHIPS_WRAP_ATTR, 'true');
wrap.className = 'goodlib-chip-wrap';
title.appendChild(wrap);
}
}
const orderedChips = [];
for (const source of SOURCE_ORDER) {
if (!enabledBySource[source]) continue;
const src = SOURCE_MAP[source];
const q = src.titleOnly ? bookTitle : searchQuery;
let chip = wrap.querySelector('[' + CHIP_ATTR + '="' + source + '"]');
if (!(chip instanceof HTMLElement)) chip = makeChip(source, q);
chip.setAttribute('data-search-query', q);
orderedChips.push(chip);
}
const currentOrder = Array.from(wrap.children).filter(
node => node instanceof HTMLElement && node.hasAttribute(CHIP_ATTR)
);
const needsReorder =
currentOrder.length !== orderedChips.length ||
currentOrder.some((node, i) => node !== orderedChips[i]);
if (needsReorder) wrap.replaceChildren(...orderedChips);
}
function isAnyEnabled() {
return SOURCE_ORDER.some(s => enabledBySource[s]);
}
function syncChipToState() {
if (!isAnyEnabled()) { removeChip(); return; }
injectChips(enabledBySource);
}
// Initialize from GM storage
async function initializeEnabledState() {
try {
for (const source of SOURCE_ORDER) {
const val = (typeof GM !== 'undefined' && GM.getValue)
? await GM.getValue(ENABLED_KEYS[source], enabledBySource[source])
: enabledBySource[source];
enabledBySource[source] = typeof val === 'boolean' ? val : enabledBySource[source];
lastStored[source] = enabledBySource[source];
}
for (const source of SOURCE_ORDER) {
const val = (typeof GM !== 'undefined' && GM.getValue)
? await GM.getValue(MIRROR_KEYS[source], MIRRORS[source][0])
: MIRRORS[source][0];
if (typeof val === 'string' && MIRRORS[source].includes(val)) selectedMirror[source] = val;
}
syncChipToState();
} catch (err) {
console.error('GoodLib userscript: failed to read stored settings', err);
syncChipToState();
}
}
initializeEnabledState();
let injectTimeout = null;
const observer = new MutationObserver(() => {
if (!isAnyEnabled()) return;
if (injectTimeout) clearTimeout(injectTimeout);
injectTimeout = setTimeout(() => injectChips(enabledBySource), 120);
});
observer.observe(document.body, { childList: true, subtree: true });
async function pollStorage() {
try {
let changed = false;
for (const source of SOURCE_ORDER) {
const val = (typeof GM !== 'undefined' && GM.getValue)
? await GM.getValue(ENABLED_KEYS[source], enabledBySource[source])
: enabledBySource[source];
if (val !== lastStored[source]) {
enabledBySource[source] = typeof val === 'boolean' ? val : enabledBySource[source];
lastStored[source] = val;
changed = true;
}
}
if (changed) syncChipToState();
} catch (err) {}
}
setInterval(pollStorage, 1500);
const GEAR_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 12.94a7.14 7.14 0 0 0 0-1.88l2.03-1.57a.5.5 0 0 0 .12-.65l-1.93-3.34a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.73-.99l-.36-2.54A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.49.42l-.36 2.54c-.6.24-1.17.56-1.73.99l-2.39-.96a.5.5 0 0 0-.6.22L1.71 8.83a.5.5 0 0 0 .12.65L3.86 11.05a7.14 7.14 0 0 0 0 1.9L1.83 14.52a.5.5 0 0 0-.12.65l1.93 3.34c.14.24.44.34.69.22l2.39-.96c.56.43 1.13.75 1.73.99l.36 2.54A.5.5 0 0 0 10 22h4a.5.5 0 0 0 .49-.42l.36-2.54c.6-.24 1.17-.56 1.73-.99l2.39.96c.25.12.55.02.69-.22l1.93-3.34a.5.5 0 0 0-.12-.65l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z" fill="currentColor"/></svg>';
const CHEVRON_SVG = '<svg width="12" height="12" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="goodlib-chevron"><path d="M233.4 406.6a32.05 32.05 0 0 0 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>';
const createSettingsButton = () => {
const btn = document.createElement('button');
btn.className = `goodlib-settings-button goodlib-settings-button--${SITE}`;
btn.type = 'button';
btn.title = 'GoodLib Settings';
btn.innerHTML = SITE === 'hardcover'
? `${GEAR_SVG} GoodLib ${CHEVRON_SVG}`
: GEAR_SVG;
return btn;
};
const createPopup = () => {
const popup = document.createElement('div');
popup.className = 'goodlib-settings-popup' + (SITE === 'hardcover' ? ' goodlib-popup--hardcover' : '');
if (SITE === 'hardcover') {
const nip = document.createElement('div');
nip.className = 'goodlib-popup-nip';
popup.prepend(nip);
}
const header = document.createElement('div');
header.className = 'goodlib-card-header';
header.innerHTML = '<span class="goodlib-card-title">Good<span style="color:#f0a500">LIB</span></span>';
popup.appendChild(header);
for (const src of SOURCES) {
const row = document.createElement('div');
row.className = 'goodlib-row';
const avatar = document.createElement('span');
avatar.className = `goodlib-avatar ${src.key}`;
avatar.textContent = src.popupAvatar;
const meta = document.createElement('div');
meta.className = 'goodlib-meta';
const nameEl = document.createElement('div');
nameEl.className = 'goodlib-name';
nameEl.textContent = src.popupName;
meta.appendChild(nameEl);
if (src.mirrors.length > 1) {
const domainSelect = document.createElement('select');
domainSelect.className = 'goodlib-domain goodlib-domain-select';
domainSelect.setAttribute('data-mirror-source', src.key);
for (const m of src.mirrors) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === selectedMirror[src.key]) opt.selected = true;
domainSelect.appendChild(opt);
}
meta.appendChild(domainSelect);
} else {
const domainEl = document.createElement('div');
domainEl.className = 'goodlib-domain';
domainEl.textContent = selectedMirror[src.key];
meta.appendChild(domainEl);
}
const toggle = document.createElement('label');
toggle.className = 'goodlib-toggle';
toggle.setAttribute('data-source', src.key);
toggle.innerHTML = `<input type="checkbox" data-key="${src.enabledKey}"><span class="slider"></span>`;
row.append(avatar, meta, toggle);
popup.appendChild(row);
}
return popup;
};
const setStored = async (key, value) => {
try {
if (typeof GM !== 'undefined' && GM.setValue) await GM.setValue(key, value);
else localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
};
const getStored = async (key, fallback) => {
try {
if (typeof GM !== 'undefined' && GM.getValue) return await GM.getValue(key, fallback);
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (e) { return fallback; }
};
function getPageBg() {
let bg = getComputedStyle(document.body).backgroundColor;
if (!bg || bg === 'rgba(0, 0, 0, 0)') bg = getComputedStyle(document.documentElement).backgroundColor;
if (!bg || bg === 'rgba(0, 0, 0, 0)') bg = '#ffffff';
return bg;
}
function isPageDark() {
if (SITE === 'hardcover') return document.documentElement.classList.contains('dark');
const bg = getPageBg();
const m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return window.matchMedia('(prefers-color-scheme: dark)').matches;
const lum = (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) / 255;
return lum < 0.5;
}
let settingsPopup = null;
let hcHoverTimeout = null;
const hcClearHover = () => { if (hcHoverTimeout) { clearTimeout(hcHoverTimeout); hcHoverTimeout = null; } };
const hcScheduleClose = () => { hcClearHover(); hcHoverTimeout = setTimeout(closeSettings, 150); };
const openSettings = async (btn) => {
if (!settingsPopup) settingsPopup = createPopup();
// Sync checkboxes
for (const inp of settingsPopup.querySelectorAll('input[type=checkbox]')) {
const key = inp.getAttribute('data-key');
const src = SOURCES.find(s => s.enabledKey === key);
const val = await getStored(key, enabledBySource[src.key]);
inp.checked = !!val;
inp.onchange = async () => {
const next = inp.checked;
await setStored(key, next);
enabledBySource[src.key] = next;
syncChipToState();
};
}
// Sync mirror sites selections
for (const sel of settingsPopup.querySelectorAll('.goodlib-domain-select')) {
const source = sel.getAttribute('data-mirror-source');
sel.value = selectedMirror[source];
sel.onchange = async () => {
selectedMirror[source] = sel.value;
await setStored(MIRROR_KEYS[source], sel.value);
syncChipToState();
};
}
btn.classList.add('goodlib-open');
if (SITE !== 'hardcover') settingsPopup.style.background = getPageBg();
settingsPopup.classList.toggle('goodlib-dark', isPageDark());
document.body.appendChild(settingsPopup);
const rect = btn.getBoundingClientRect();
const popupWidth = settingsPopup.offsetWidth;
const popupLeft = Math.max(8, rect.right + window.scrollX - popupWidth);
settingsPopup.style.top = (rect.bottom + window.scrollY + 8) + 'px';
settingsPopup.style.left = popupLeft + 'px';
if (SITE === 'hardcover') {
const nipEl = settingsPopup.querySelector('.goodlib-popup-nip');
if (nipEl) nipEl.style.left = Math.max(8, Math.round(popupWidth - rect.width / 2) - 10) + 'px';
}
const onEsc = (e) => { if (e.key === 'Escape') closeSettings(); };
document.addEventListener('keydown', onEsc);
if (SITE === 'hardcover') {
settingsPopup.addEventListener('mouseenter', hcClearHover);
settingsPopup.addEventListener('mouseleave', hcScheduleClose);
settingsPopup._cleanup = () => document.removeEventListener('keydown', onEsc);
} else {
const onDocClick = (e) => { if (!settingsPopup.contains(e.target) && e.target !== btn) closeSettings(); };
document.addEventListener('click', onDocClick);
settingsPopup._cleanup = () => {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onEsc);
};
}
};
const closeSettings = () => {
if (!settingsPopup) return;
if (settingsPopup._cleanup) settingsPopup._cleanup();
settingsPopup.remove();
settingsPopup = null;
document.querySelector('.goodlib-settings-button')?.classList.remove('goodlib-open');
};
const tryInsertSettingsButton = () => {
if (document.querySelector('.goodlib-settings-button')) return;
const btn = createSettingsButton();
if (SITE === 'hardcover') {
btn.addEventListener('mouseenter', () => { hcClearHover(); if (!settingsPopup) openSettings(btn); });
btn.addEventListener('mouseleave', hcScheduleClose);
} else {
btn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPopup ? closeSettings() : openSettings(btn);
});
}
if (SITE === 'storygraph') {
const signInLink = document.querySelector('a[href="/users/sign_in"]');
if (!signInLink) return;
signInLink.parentNode.insertBefore(btn, signInLink.nextSibling);
} else if (SITE === 'hardcover') {
const discoverBtn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent.trim().startsWith('Discover')
);
if (discoverBtn) {
const wrapperDiv = discoverBtn.parentElement;
const groupDiv = wrapperDiv?.parentElement;
if (groupDiv?.parentElement) {
groupDiv.parentElement.insertBefore(btn, groupDiv.nextSibling);
return;
}
}
// fallback before Login
const loginBtn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent.trim() === 'Login'
);
if (!loginBtn) return;
loginBtn.parentNode.insertBefore(btn, loginBtn);
} else {
const searchContainer = document.querySelector('.Header__searchContainer')
|| document.querySelector('.HeaderSearch')
|| document.querySelector('.Header__contents');
if (!searchContainer) return;
if (searchContainer.parentNode) searchContainer.parentNode.insertBefore(btn, searchContainer.nextSibling);
}
};
tryInsertSettingsButton();
const headerObserver = new MutationObserver(() => tryInsertSettingsButton());
headerObserver.observe(document.body, { childList: true, subtree: true });
// SPA navigation detection for StoryGraph and Hardcover
if (SITE === 'storygraph' || SITE === 'hardcover') {
let lastPath = location.pathname;
const onSpaNavigate = () => {
if (location.pathname === lastPath) return;
lastPath = location.pathname;
[300, 700, 1500].forEach(delay => {
setTimeout(() => syncChipToState(), delay);
setTimeout(() => tryInsertSettingsButton(), delay);
});
};
const _push = history.pushState.bind(history);
history.pushState = (...a) => { _push(...a); onSpaNavigate(); };
const _replace = history.replaceState.bind(history);
history.replaceState = (...a) => { _replace(...a); onSpaNavigate(); };
window.addEventListener('popstate', onSpaNavigate);
}
})();