Skip to content

Instantly share code, notes, and snippets.

@chenasraf
Last active July 29, 2026 14:26
Show Gist options
  • Select an option

  • Save chenasraf/6bfd798958d876b2b123ce395f9298b4 to your computer and use it in GitHub Desktop.

Select an option

Save chenasraf/6bfd798958d876b2b123ce395f9298b4 to your computer and use it in GitHub Desktop.
Nextcloud App Store — inline screenshot modal
// ==UserScript==
// @name Nextcloud App Store — inline screenshot modal
// @namespace http://tampermonkey.net/
// @version 2026-07-25
// @description Replace the native fullscreen screenshot preview with an inline modal that covers most of the page and can be dismissed (Esc, backdrop click, or ×). Adds prev/next navigation across the gallery.
// @author You
// @match https://apps.nextcloud.com/apps/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=nextcloud.com
// @downloadURL https://gist.githubusercontent.com/chenasraf/6bfd798958d876b2b123ce395f9298b4/raw/userscript.js
// @updateURL https://gist.githubusercontent.com/chenasraf/6bfd798958d876b2b123ce395f9298b4/raw/userscript.js
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const IMG_SEL = '#app-gallery .carousel-inner .item img';
const images = () => Array.from(document.querySelectorAll(IMG_SEL));
// Styles are applied via the CSSOM (element.style.*) rather than an injected
// <style> element, because the app store's Content-Security-Policy blocks
// injected stylesheets — that was why the overlay rendered unstyled.
const css = (el, styles) => Object.assign(el.style, styles);
// Give a button an opacity hover effect without CSS :hover.
const hover = (el, base, over) => {
el.style.opacity = base;
el.addEventListener('mouseenter', () => { el.style.opacity = over; });
el.addEventListener('mouseleave', () => { el.style.opacity = base; });
};
// ---- Modal construction -------------------------------------------------
const overlay = document.createElement('div');
overlay.id = 'ncsm-overlay';
css(overlay, {
position: 'fixed',
top: '0', right: '0', bottom: '0', left: '0',
zIndex: '100000',
display: 'none',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(2px)',
});
const modalImg = document.createElement('img');
modalImg.alt = 'Screenshot';
css(modalImg, {
maxWidth: '92vw',
maxHeight: '92vh',
width: 'auto',
height: 'auto',
objectFit: 'contain',
boxShadow: '0 8px 40px rgba(0, 0, 0, 0.6)',
borderRadius: '4px',
background: '#fff',
});
const btnClose = document.createElement('button');
btnClose.setAttribute('aria-label', 'Close');
btnClose.innerHTML = '&times;';
css(btnClose, {
position: 'fixed', top: '16px', right: '24px',
fontSize: '40px', lineHeight: '1', color: '#fff',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 12px',
});
hover(btnClose, '0.8', '1');
const mkNav = (label, glyph, side) => {
const b = document.createElement('button');
b.setAttribute('aria-label', label);
b.innerHTML = glyph;
css(b, {
position: 'fixed', top: '50%', transform: 'translateY(-50%)',
[side]: '16px',
fontSize: '44px', color: '#fff',
background: 'rgba(0, 0, 0, 0.3)', border: 'none', cursor: 'pointer',
padding: '12px 18px', borderRadius: '8px', userSelect: 'none',
});
hover(b, '0.75', '1');
return b;
};
const btnPrev = mkNav('Previous', '&#10094;', 'left');
const btnNext = mkNav('Next', '&#10095;', 'right');
overlay.append(btnClose, btnPrev, modalImg, btnNext);
document.body.appendChild(overlay);
let current = 0;
function show(index) {
const list = images();
if (!list.length) return;
current = (index + list.length) % list.length;
modalImg.src = list[current].src;
modalImg.alt = list[current].alt || 'Screenshot';
const single = list.length < 2;
btnPrev.style.display = single ? 'none' : '';
btnNext.style.display = single ? 'none' : '';
overlay.style.display = 'flex';
}
function close() {
overlay.style.display = 'none';
modalImg.removeAttribute('src');
}
const isOpen = () => overlay.style.display !== 'none';
// ---- Intercept the native fullscreen click ------------------------------
// Their handler lives on `.carousel-inner` (bubble phase) and calls the
// Fullscreen API. Delegating from `document` in the CAPTURE phase catches
// the click first and stopImmediatePropagation prevents their handler.
document.addEventListener('click', (e) => {
const img = e.target.closest && e.target.closest('img');
if (!img || !img.closest('#app-gallery')) return;
e.preventDefault();
e.stopImmediatePropagation();
const idx = images().indexOf(img);
show(idx < 0 ? 0 : idx);
}, true);
// ---- Modal controls -----------------------------------------------------
btnClose.addEventListener('click', close);
btnPrev.addEventListener('click', () => show(current - 1));
btnNext.addEventListener('click', () => show(current + 1));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
document.addEventListener('keydown', (e) => {
if (!isOpen()) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') show(current - 1);
else if (e.key === 'ArrowRight') show(current + 1);
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment