Created
March 5, 2026 05:16
-
-
Save kiranwayne/2268670ef926ac23edf2bbb6d95b4000 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Goodreads Enhanced | |
| // @namespace https://www.goodreads.com/ | |
| // @version 2.5.0 | |
| // @description Width control UI for Goodreads home, book, and author pages with book reviews-focused expansion. | |
| // @author You | |
| // @match https://www.goodreads.com/ | |
| // @match https://www.goodreads.com/?* | |
| // @match https://www.goodreads.com/book/show/* | |
| // @match https://www.goodreads.com/author/show/* | |
| // @updateURL https://gist.github.com/kiranwayne/2268670ef926ac23edf2bbb6d95b4000/raw/goodreads_enhanced.js | |
| // @downloadURL https://gist.github.com/kiranwayne/2268670ef926ac23edf2bbb6d95b4000/raw/goodreads_enhanced.js | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const STORAGE_KEY = 'grWideLayoutSettingsV2'; | |
| const MIN_VIEWPORT = 1400; | |
| const MAX_WIDE_MODE_SIDE_GAP = 24; // "max width" from previous version | |
| const defaultSettings = { | |
| slider: 100, | |
| useDefaultWidth: false, | |
| panelVisible: false, | |
| panelX: 20, | |
| panelY: 80 | |
| }; | |
| const settings = loadSettings(); | |
| let uiStyleEl = null; | |
| let layoutStyleEl = null; | |
| let panelEl = null; | |
| let toggleBtnEl = null; | |
| let sliderEl = null; | |
| let checkboxEl = null; | |
| let sliderValueEl = null; | |
| let metricsEl = null; | |
| function loadSettings() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (!raw) return { ...defaultSettings }; | |
| const parsed = JSON.parse(raw); | |
| return { | |
| ...defaultSettings, | |
| ...parsed, | |
| slider: clampNumber(parsed.slider, 0, 100, defaultSettings.slider), | |
| panelX: clampNumber(parsed.panelX, 0, 99999, defaultSettings.panelX), | |
| panelY: clampNumber(parsed.panelY, 0, 99999, defaultSettings.panelY) | |
| }; | |
| } catch { | |
| return { ...defaultSettings }; | |
| } | |
| } | |
| function saveSettings() { | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); | |
| } catch { | |
| // ignore storage failures | |
| } | |
| } | |
| function clampNumber(value, min, max, fallback) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return fallback; | |
| return Math.min(max, Math.max(min, n)); | |
| } | |
| function ensureStyles() { | |
| if (!uiStyleEl) { | |
| uiStyleEl = document.createElement('style'); | |
| uiStyleEl.id = 'grwl-ui-style'; | |
| uiStyleEl.textContent = ` | |
| #grwl-toggle-btn { | |
| position: fixed; | |
| right: 16px; | |
| bottom: 16px; | |
| z-index: 2147483646; | |
| border: 1px solid rgba(255,255,255,0.35); | |
| border-radius: 8px; | |
| background: rgba(15, 15, 18, 0.78); | |
| color: #f5f5f5; | |
| padding: 8px 12px; | |
| font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| cursor: pointer; | |
| backdrop-filter: blur(6px); | |
| } | |
| #grwl-panel { | |
| position: fixed; | |
| z-index: 2147483647; | |
| width: 320px; | |
| color: #f2f2f2; | |
| border: 1px solid rgba(255,255,255,0.24); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| background: rgba(20, 21, 24, 0.76); | |
| backdrop-filter: blur(10px); | |
| box-shadow: 0 12px 36px rgba(0,0,0,0.45); | |
| font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| } | |
| #grwl-panel.grwl-hidden { | |
| display: none; | |
| } | |
| #grwl-panel-header { | |
| padding: 9px 12px; | |
| font-weight: 700; | |
| letter-spacing: 0.2px; | |
| background: rgba(45, 47, 54, 0.8); | |
| border-bottom: 1px solid rgba(255,255,255,0.14); | |
| cursor: move; | |
| user-select: none; | |
| } | |
| #grwl-panel-body { | |
| padding: 12px; | |
| } | |
| .grwl-row + .grwl-row { | |
| margin-top: 10px; | |
| } | |
| .grwl-checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| #grwl-slider { | |
| width: 100%; | |
| } | |
| #grwl-slider-value { | |
| font-weight: 700; | |
| color: #f4d58a; | |
| } | |
| #grwl-metrics { | |
| color: rgba(255,255,255,0.82); | |
| font-size: 12px; | |
| } | |
| .grwl-endpoints { | |
| display: flex; | |
| justify-content: space-between; | |
| color: rgba(255,255,255,0.62); | |
| font-size: 11px; | |
| margin-top: 2px; | |
| } | |
| `; | |
| document.documentElement.appendChild(uiStyleEl); | |
| } | |
| if (!layoutStyleEl) { | |
| layoutStyleEl = document.createElement('style'); | |
| layoutStyleEl.id = 'grwl-layout-style'; | |
| document.documentElement.appendChild(layoutStyleEl); | |
| } | |
| } | |
| function buildLayoutCss(sideGapPx) { | |
| return ` | |
| @media (min-width: ${MIN_VIEWPORT}px) { | |
| .PageFrame, | |
| .siteHeader__contents, | |
| main, | |
| .mainContent, | |
| .mainContentContainer, | |
| .gr-mainContentContainer { | |
| max-width: none !important; | |
| } | |
| .PageFrame, | |
| .siteHeader__contents, | |
| main, | |
| .mainContent { | |
| width: calc(100vw - ${sideGapPx * 2}px) !important; | |
| margin-left: ${sideGapPx}px !important; | |
| margin-right: ${sideGapPx}px !important; | |
| } | |
| /* Goodreads book/review pages use centered PageFrame wrapper */ | |
| main.PageFrame__main { | |
| width: 100% !important; | |
| margin-left: 0 !important; | |
| margin-right: 0 !important; | |
| } | |
| /* ── Book + Review pages: expand the outer PageFrame ── */ | |
| html.grwl-page-book .PageFrame { | |
| width: calc(100vw - ${sideGapPx * 2}px) !important; | |
| max-width: none !important; | |
| margin-left: ${sideGapPx}px !important; | |
| margin-right: ${sideGapPx}px !important; | |
| } | |
| html.grwl-page-book main.PageFrame__main { | |
| display: block !important; | |
| width: 100% !important; | |
| max-width: none !important; | |
| margin-left: 0 !important; | |
| margin-right: 0 !important; | |
| } | |
| /* ── Book Show page: override the fixed CSS-grid container ── */ | |
| html.grwl-page-book-show .BookPage__gridContainer { | |
| display: flex !important; | |
| width: 100% !important; | |
| max-width: none !important; | |
| gap: 16px !important; | |
| grid-template-columns: none !important; | |
| } | |
| html.grwl-page-book-show .BookPage__leftColumn { | |
| flex: 0 0 297px !important; | |
| width: 297px !important; | |
| min-width: 297px !important; | |
| max-width: 297px !important; | |
| margin: 0 !important; | |
| grid-column: unset !important; | |
| } | |
| html.grwl-page-book-show .BookPage__bookCover { | |
| width: 208px !important; | |
| min-width: 208px !important; | |
| max-width: 208px !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| } | |
| html.grwl-page-book-show .BookPage__bookCover img { | |
| width: 100% !important; | |
| max-width: 208px !important; | |
| height: auto !important; | |
| } | |
| html.grwl-page-book-show .BookPage__rightColumn { | |
| flex: 1 1 auto !important; | |
| width: auto !important; | |
| max-width: none !important; | |
| min-width: 0 !important; | |
| margin: 0 !important; | |
| padding-left: 0 !important; | |
| grid-column: unset !important; | |
| } | |
| html.grwl-page-book-show #ReviewsSection, | |
| html.grwl-page-book-show .ReviewsSection, | |
| html.grwl-page-book-show .ReviewsList { | |
| width: 100% !important; | |
| max-width: none !important; | |
| } | |
| /* Keep top review-area card covers from scaling with slider width */ | |
| html.grwl-page-book-show .CarouselGroup__item--4-col { | |
| flex: 0 0 195px !important; | |
| width: 195px !important; | |
| max-width: 195px !important; | |
| } | |
| html.grwl-page-book-show .BookCard .BookCover { | |
| width: 195px !important; | |
| max-width: 195px !important; | |
| } | |
| html.grwl-page-book-show .BookCard .BookCover .ResponsiveImage { | |
| width: 195px !important; | |
| max-width: 195px !important; | |
| height: auto !important; | |
| } | |
| /* ── Book Reviews page: override the fixed CSS-grid container ── */ | |
| html.grwl-page-book-reviews .BookReviewsPage__gridContainer { | |
| display: flex !important; | |
| width: 100% !important; | |
| max-width: none !important; | |
| gap: 16px !important; | |
| grid-template-columns: none !important; | |
| } | |
| html.grwl-page-book-reviews .BookReviewsPage__leftColumn { | |
| flex: 0 0 291px !important; | |
| width: 291px !important; | |
| min-width: 291px !important; | |
| max-width: 291px !important; | |
| margin: 0 !important; | |
| grid-column: unset !important; | |
| } | |
| html.grwl-page-book-reviews .BookReviewsPage__rightColumn { | |
| flex: 1 1 auto !important; | |
| width: auto !important; | |
| max-width: none !important; | |
| min-width: 0 !important; | |
| margin: 0 !important; | |
| grid-column: unset !important; | |
| } | |
| html.grwl-page-book-reviews .ReviewsList { | |
| width: 100% !important; | |
| max-width: none !important; | |
| } | |
| /* Legacy Goodreads author pages */ | |
| html.grwl-page-author .mainContent > .mainContentFloat { | |
| width: 100% !important; | |
| max-width: none !important; | |
| float: none !important; | |
| margin: 0 !important; | |
| padding-left: 0 !important; | |
| padding-right: 0 !important; | |
| box-sizing: border-box !important; | |
| } | |
| html.grwl-page-author .mainContent .reverseColumnSizes { | |
| width: 100% !important; | |
| max-width: none !important; | |
| display: grid !important; | |
| grid-template-columns: minmax(280px, 340px) minmax(680px, 1fr) !important; | |
| column-gap: 20px !important; | |
| align-items: start !important; | |
| } | |
| html.grwl-page-author .mainContent .reverseColumnSizes > .leftContainer { | |
| grid-column: 1 !important; | |
| } | |
| html.grwl-page-author .mainContent .reverseColumnSizes > .rightContainer { | |
| grid-column: 2 !important; | |
| } | |
| html.grwl-page-author .mainContent .leftContainer, | |
| html.grwl-page-author .mainContent .rightContainer { | |
| float: none !important; | |
| width: auto !important; | |
| min-width: 0 !important; | |
| margin: 0 !important; | |
| } | |
| /* Goodreads home page uses floated 3-column layout; convert to responsive grid */ | |
| body.gr-homePageBody main { | |
| display: grid !important; | |
| grid-template-columns: minmax(260px, 300px) minmax(720px, 1fr) minmax(260px, 300px) !important; | |
| column-gap: 20px !important; | |
| align-items: start !important; | |
| } | |
| body.gr-homePageBody main::after { | |
| content: none !important; | |
| display: none !important; | |
| } | |
| body.gr-homePageBody main > .homeSecondaryColumn, | |
| body.gr-homePageBody main > .homePrimaryColumn, | |
| body.gr-homePageBody main > .homeTertiaryColumn { | |
| float: none !important; | |
| width: auto !important; | |
| margin: 12px 0 0 0 !important; | |
| } | |
| body.gr-homePageBody main > .homePrimaryColumn { | |
| min-width: 0 !important; | |
| } | |
| } | |
| `; | |
| } | |
| function updatePageTypeClasses() { | |
| const html = document.documentElement; | |
| if (!html) return; | |
| html.classList.remove('grwl-page-author', 'grwl-page-book', 'grwl-page-book-show', 'grwl-page-book-reviews'); | |
| const path = location.pathname || ''; | |
| if (path.startsWith('/author/show/')) { | |
| html.classList.add('grwl-page-author'); | |
| } | |
| if (path.startsWith('/book/show/')) { | |
| html.classList.add('grwl-page-book'); | |
| if (path.includes('/reviews')) { | |
| html.classList.add('grwl-page-book-reviews'); | |
| } else { | |
| html.classList.add('grwl-page-book-show'); | |
| } | |
| } | |
| } | |
| function getElementSideGap(el) { | |
| if (!el) return null; | |
| const rect = el.getBoundingClientRect(); | |
| if (!rect || rect.width < 320) return null; | |
| const leftGap = rect.left; | |
| const rightGap = window.innerWidth - rect.right; | |
| return Math.max(0, Math.round(Math.min(leftGap, rightGap))); | |
| } | |
| function measureDefaultSideGap() { | |
| const previousCss = layoutStyleEl ? layoutStyleEl.textContent : ''; | |
| if (layoutStyleEl) { | |
| layoutStyleEl.textContent = ''; | |
| } | |
| const candidates = [ | |
| ['.PageFrame', document.querySelector('.PageFrame')], | |
| ['main', document.querySelector('main')], | |
| ['.siteHeader__contents', document.querySelector('.siteHeader__contents')], | |
| ['.mainContent', document.querySelector('.mainContent')], | |
| ['.gr-mainContentContainer', document.querySelector('.gr-mainContentContainer')] | |
| ]; | |
| const measured = []; | |
| for (const [name, el] of candidates) { | |
| const gap = getElementSideGap(el); | |
| if (gap == null) continue; | |
| measured.push({ name, gap }); | |
| } | |
| if (layoutStyleEl) { | |
| layoutStyleEl.textContent = previousCss; | |
| } | |
| if (measured.length === 0) return null; | |
| // Prefer a truly constrained centered container when available. | |
| const constrained = measured.find((m) => m.gap >= 40); | |
| return (constrained || measured[0]).gap; | |
| } | |
| function computeSideGap(defaultGap) { | |
| const sliderRatio = clampNumber(settings.slider, 0, 100, 100) / 100; | |
| const minGap = Math.min(MAX_WIDE_MODE_SIDE_GAP, defaultGap); | |
| const customGap = Math.round(defaultGap - ((defaultGap - minGap) * sliderRatio)); | |
| return { | |
| minGap, | |
| customGap | |
| }; | |
| } | |
| function applyLayout() { | |
| if (!layoutStyleEl) return; | |
| updatePageTypeClasses(); | |
| if (window.innerWidth < MIN_VIEWPORT) { | |
| layoutStyleEl.textContent = ''; | |
| updateMetrics(null, null, null, 'Viewport too small (layout untouched).'); | |
| return; | |
| } | |
| const defaultGap = measureDefaultSideGap(); | |
| if (defaultGap == null) { | |
| layoutStyleEl.textContent = ''; | |
| updateMetrics(null, null, null, 'Main content not found yet.'); | |
| return; | |
| } | |
| const { minGap, customGap } = computeSideGap(defaultGap); | |
| const useDefaultNow = settings.useDefaultWidth || settings.slider <= 0 || customGap >= defaultGap; | |
| if (useDefaultNow) { | |
| layoutStyleEl.textContent = ''; | |
| updateMetrics(defaultGap, defaultGap, minGap, 'Using Goodreads default width'); | |
| } else { | |
| layoutStyleEl.textContent = buildLayoutCss(customGap); | |
| updateMetrics(defaultGap, customGap, minGap, 'Custom width active'); | |
| } | |
| updateSliderReadout(defaultGap, minGap); | |
| } | |
| function updateSliderReadout(defaultGap, minGap) { | |
| if (!sliderValueEl) return; | |
| const pct = Math.round(clampNumber(settings.slider, 0, 100, 100)); | |
| sliderValueEl.textContent = settings.useDefaultWidth ? 'Default' : `${pct}% wider`; | |
| if (sliderEl) { | |
| sliderEl.disabled = !!settings.useDefaultWidth; | |
| sliderEl.title = `Default gap: ${defaultGap ?? '?'}px, Wide mode min gap: ${minGap ?? '?'}px`; | |
| } | |
| } | |
| function updateMetrics(defaultGap, currentGap, minGap, status) { | |
| if (!metricsEl) return; | |
| if (defaultGap == null || currentGap == null) { | |
| metricsEl.textContent = status || ''; | |
| return; | |
| } | |
| const defaultWidth = Math.max(0, window.innerWidth - (defaultGap * 2)); | |
| const currentWidth = Math.max(0, window.innerWidth - (currentGap * 2)); | |
| metricsEl.textContent = `${status} • default ${defaultWidth}px, current ${currentWidth}px • gap ${currentGap}px (min ${minGap}px)`; | |
| } | |
| function setPanelVisibility(visible) { | |
| settings.panelVisible = !!visible; | |
| if (panelEl) { | |
| panelEl.classList.toggle('grwl-hidden', !settings.panelVisible); | |
| } | |
| if (toggleBtnEl) { | |
| toggleBtnEl.textContent = settings.panelVisible ? 'Hide Settings' : 'Show Settings'; | |
| } | |
| saveSettings(); | |
| } | |
| function setPanelPosition(x, y) { | |
| settings.panelX = clampNumber(x, 0, Math.max(0, window.innerWidth - 80), defaultSettings.panelX); | |
| settings.panelY = clampNumber(y, 0, Math.max(0, window.innerHeight - 40), defaultSettings.panelY); | |
| if (panelEl) { | |
| panelEl.style.left = `${settings.panelX}px`; | |
| panelEl.style.top = `${settings.panelY}px`; | |
| } | |
| } | |
| function setupDragging(headerEl) { | |
| let dragging = false; | |
| let dx = 0; | |
| let dy = 0; | |
| headerEl.addEventListener('pointerdown', (event) => { | |
| dragging = true; | |
| headerEl.setPointerCapture(event.pointerId); | |
| const rect = panelEl.getBoundingClientRect(); | |
| dx = event.clientX - rect.left; | |
| dy = event.clientY - rect.top; | |
| }); | |
| headerEl.addEventListener('pointermove', (event) => { | |
| if (!dragging) return; | |
| setPanelPosition(event.clientX - dx, event.clientY - dy); | |
| }); | |
| function stopDrag(event) { | |
| if (!dragging) return; | |
| dragging = false; | |
| try { | |
| headerEl.releasePointerCapture(event.pointerId); | |
| } catch { | |
| // ignore | |
| } | |
| saveSettings(); | |
| } | |
| headerEl.addEventListener('pointerup', stopDrag); | |
| headerEl.addEventListener('pointercancel', stopDrag); | |
| } | |
| function createUI() { | |
| if (toggleBtnEl || panelEl) return; | |
| toggleBtnEl = document.createElement('button'); | |
| toggleBtnEl.id = 'grwl-toggle-btn'; | |
| toggleBtnEl.type = 'button'; | |
| toggleBtnEl.textContent = settings.panelVisible ? 'Hide Settings' : 'Show Settings'; | |
| toggleBtnEl.addEventListener('click', () => setPanelVisibility(!settings.panelVisible)); | |
| panelEl = document.createElement('div'); | |
| panelEl.id = 'grwl-panel'; | |
| if (!settings.panelVisible) { | |
| panelEl.classList.add('grwl-hidden'); | |
| } | |
| panelEl.innerHTML = ` | |
| <div id="grwl-panel-header">Goodreads Width Settings</div> | |
| <div id="grwl-panel-body"> | |
| <div class="grwl-row"> | |
| <label class="grwl-checkbox-label"> | |
| <input id="grwl-default-checkbox" type="checkbox"> | |
| <span>Use Goodreads default width</span> | |
| </label> | |
| </div> | |
| <div class="grwl-row"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;"> | |
| <span>Main content width</span> | |
| <span id="grwl-slider-value"></span> | |
| </div> | |
| <input id="grwl-slider" type="range" min="0" max="100" step="1"> | |
| <div class="grwl-endpoints"><span>Default</span><span>Max (wide mode)</span></div> | |
| </div> | |
| <div class="grwl-row" id="grwl-metrics"></div> | |
| </div> | |
| `; | |
| checkboxEl = panelEl.querySelector('#grwl-default-checkbox'); | |
| sliderEl = panelEl.querySelector('#grwl-slider'); | |
| sliderValueEl = panelEl.querySelector('#grwl-slider-value'); | |
| metricsEl = panelEl.querySelector('#grwl-metrics'); | |
| checkboxEl.checked = !!settings.useDefaultWidth; | |
| sliderEl.value = String(clampNumber(settings.slider, 0, 100, 100)); | |
| checkboxEl.addEventListener('change', () => { | |
| settings.useDefaultWidth = checkboxEl.checked; | |
| saveSettings(); | |
| applyLayout(); | |
| }); | |
| sliderEl.addEventListener('input', () => { | |
| settings.slider = clampNumber(sliderEl.value, 0, 100, 100); | |
| saveSettings(); | |
| applyLayout(); | |
| }); | |
| setPanelPosition(settings.panelX, settings.panelY); | |
| setupDragging(panelEl.querySelector('#grwl-panel-header')); | |
| document.documentElement.appendChild(toggleBtnEl); | |
| document.documentElement.appendChild(panelEl); | |
| } | |
| function init() { | |
| ensureStyles(); | |
| createUI(); | |
| applyLayout(); | |
| let resizeTimer = null; | |
| window.addEventListener('resize', () => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(() => { | |
| setPanelPosition(settings.panelX, settings.panelY); | |
| applyLayout(); | |
| }, 120); | |
| }); | |
| // Apply again after late page render/layout shifts. | |
| setTimeout(applyLayout, 300); | |
| setTimeout(applyLayout, 1200); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init, { once: true }); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment