Skip to content

Instantly share code, notes, and snippets.

@Strajk
Created October 19, 2025 08:00
Show Gist options
  • Select an option

  • Save Strajk/dbad3120a46a1ee7656b565c581e1acb to your computer and use it in GitHub Desktop.

Select an option

Save Strajk/dbad3120a46a1ee7656b565c581e1acb to your computer and use it in GitHub Desktop.
Pinkbike Photoepics Fullscreen Viewer - enhances photo epic articles with a fullscreen viewer and keyboard navigation

Pinkbike publishes photoepics. URLs look like https://www.pinkbike.com/news/photo-epic-final-practice-at-red-bull-rampage-2025.html

  • there's "/news/"
  • there's a "photo-epic"

These article have mainly large photos and their captions.

Let's make a userscript that allows viewing these photoepics in full screen.

HTML structure:

<div class="media-full-width"><a class="" href="https://www.pinkbike.com/photo/28910631/" style="display: block;"><div class="news-photo-element-width " style="max-width: 100%;"><div style="width: 100%; padding-bottom: 66.7%;" class="news-photo-element-height "><img class="news-photo lazyloaded" style="top: -0%; left: -0%; width: 100%; height: 100%;" alt="Jaxson Riddle flip can" data-src="https://ep1.pinkbike.org/p6pb28910631/p6pb28910631.jpg" src="https://ep1.pinkbike.org/p6pb28910631/p6pb28910631.jpg"></div></div></a><div style="width: 100%;" class="news-photo-caption">Jaxson Riddle flip can.</div></div>

Do this only on-demand, add a button to #blog-head .user-actions container

Use following button html for inspiration as it has correct styles already applied:

<button id="pTF" class="pb-button small nl ">Add to Favorites</button>

Allow navigation between photos using the arrow keys.

// ==UserScript==
// @name Pinkbike Photoepics Fullscreen Viewer
// @match https://www.pinkbike.com/news/*photo-epic*
// @grant GM_addStyle
// ==/UserScript==
/* global alert */
const DEBUG = false
const log = (...args) => { if (DEBUG) console.log("[PB Photoepics]", ...args) }
// eslint-disable-next-line no-undef
GM_addStyle(`
#pb-photoepic-fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
}
#pb-photoepic-fullscreen.pb-pe-active {
display: flex;
}
#pb-photoepic-fullscreen .pb-pe-image-container {
max-width: 100%;
max-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
#pb-photoepic-fullscreen img {
width: 100vw;
height: 100vh;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
#pb-photoepic-fullscreen .pb-pe-caption {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
font-size: 16px;
text-align: center;
max-width: 800px;
padding: 0 60px;
}
#pb-photoepic-fullscreen .pb-pe-close-btn {
color: white;
font-size: 12px;
opacity: 0.4;
cursor: pointer;
margin-left: 12px;
text-decoration: none;
}
#pb-photoepic-fullscreen .pb-pe-close-btn:hover {
opacity: 0.6;
}
#pb-photoepic-fullscreen .pb-pe-nav-arrows {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
pointer-events: none;
}
#pb-photoepic-fullscreen .pb-pe-nav-arrow {
background: rgba(255, 255, 255, 0.0); /* transparent */
border: none;
color: white;
padding: 12px 10px;
cursor: pointer;
font-size: 18px;
border-radius: 4px;
pointer-events: all;
user-select: none;
}
#pb-photoepic-fullscreen .pb-pe-nav-arrow:hover {
background: rgba(255, 255, 255, 0.2); /* semi-transparent */
}
#pb-photoepic-fullscreen .pb-pe-nav-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#pb-photoepic-fullscreen .pb-pe-counter {
position: fixed;
bottom: 20px;
left: 20px;
color: white;
font-size: 12px;
opacity: 0.4;
}
`)
let photos = []
let currentIndex = 0
let fullscreenContainer = null
function extractPhotos () {
log("Extracting photos...")
photos = []
const mediaElements = document.querySelectorAll(".media-full-width")
log(`Found ${mediaElements.length} media elements`)
mediaElements.forEach(el => {
const img = el.querySelector("img.news-photo")
const captionEl = el.querySelector(".news-photo-caption")
if (img) {
// Handle lazy-loaded images (data-src) and regular images (src)
const imgSrc = img.dataset.src || img.src
if (imgSrc) {
photos.push({
src: imgSrc,
alt: img.alt || "",
caption: captionEl ? captionEl.textContent.trim() : "",
})
}
}
})
log(`Extracted ${photos.length} photos`)
return photos
}
function createFullscreenViewer () {
if (fullscreenContainer) return fullscreenContainer
const container = document.createElement("div")
container.id = "pb-photoepic-fullscreen"
container.innerHTML = `
<div class="pb-pe-counter">
<span class="pb-pe-counter-text"></span>
<span class="pb-pe-close-btn">Close (ESC)</span>
</div>
<div class="pb-pe-nav-arrows">
<button class="pb-pe-nav-arrow pb-pe-prev-btn">&larr;</button>
<button class="pb-pe-nav-arrow pb-pe-next-btn">&rarr;</button>
</div>
<div class="pb-pe-image-container">
<img src="" alt="">
<div class="pb-pe-caption"></div>
</div>
`
document.body.appendChild(container)
// Event listeners
container.querySelector(".pb-pe-close-btn").addEventListener("click", closeFullscreen)
container.querySelector(".pb-pe-prev-btn").addEventListener("click", showPrevious)
container.querySelector(".pb-pe-next-btn").addEventListener("click", showNext)
// Click outside to close
container.addEventListener("click", (e) => {
if (e.target === container) {
closeFullscreen()
}
})
fullscreenContainer = container
return container
}
function showPhoto (index) {
if (!photos.length) return
currentIndex = Math.max(0, Math.min(index, photos.length - 1))
const photo = photos[currentIndex]
const container = fullscreenContainer || createFullscreenViewer()
const img = container.querySelector(".pb-pe-image-container img")
const caption = container.querySelector(".pb-pe-caption")
const counterText = container.querySelector(".pb-pe-counter-text")
const prevBtn = container.querySelector(".pb-pe-prev-btn")
const nextBtn = container.querySelector(".pb-pe-next-btn")
img.src = photo.src
img.alt = photo.alt
caption.textContent = photo.caption
counterText.textContent = `${currentIndex + 1} / ${photos.length}`
prevBtn.disabled = currentIndex === 0
nextBtn.disabled = currentIndex === photos.length - 1
log(`Showing photo ${currentIndex + 1}/${photos.length}`)
}
function showNext () {
if (currentIndex < photos.length - 1) {
showPhoto(currentIndex + 1)
}
}
function showPrevious () {
if (currentIndex > 0) {
showPhoto(currentIndex - 1)
}
}
function openFullscreen (startIndex = 0) {
log("Opening fullscreen")
extractPhotos()
if (!photos.length) {
alert("No photos found!")
return
}
const container = createFullscreenViewer()
container.classList.add("pb-pe-active")
showPhoto(startIndex)
// Prevent body scroll
document.body.style.overflow = "hidden"
}
function closeFullscreen () {
log("Closing fullscreen")
if (fullscreenContainer) {
fullscreenContainer.classList.remove("pb-pe-active")
}
// Restore body scroll
document.body.style.overflow = ""
}
function handleKeydown (e) {
if (!fullscreenContainer || !fullscreenContainer.classList.contains("pb-pe-active")) {
return
}
switch (e.key) {
case "Escape":
closeFullscreen()
e.preventDefault()
break
case "ArrowLeft":
showPrevious()
e.preventDefault()
break
case "ArrowRight":
showNext()
e.preventDefault()
break
}
}
function addButton () {
log("Adding fullscreen button")
const container = document.querySelector("#blog-head .user-actions")
if (!container) {
log("User actions container not found")
return
}
// Check if button already exists
if (container.querySelector("#pb-fullscreen-btn")) {
log("Button already exists")
return
}
const button = document.createElement("button")
button.id = "pb-fullscreen-btn"
button.className = "pb-button small nl"
button.textContent = "⛶ Fullscreen"
button.style.marginLeft = "5px"
button.addEventListener("click", () => {
openFullscreen(0)
})
container.appendChild(button)
log("Button added")
}
// Initialize
function init () {
log("Initializing...")
// Add keyboard event listener
document.addEventListener("keydown", handleKeydown)
// Wait for DOM to be ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", addButton)
} else {
addButton()
}
// Also try to add button after a delay in case the container loads later
setTimeout(addButton, 1000)
setTimeout(addButton, 2000)
}
init()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment