Last active
July 29, 2026 14:26
-
-
Save chenasraf/6bfd798958d876b2b123ce395f9298b4 to your computer and use it in GitHub Desktop.
Nextcloud App Store — inline screenshot modal
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 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 = '×'; | |
| 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', '❮', 'left'); | |
| const btnNext = mkNav('Next', '❯', '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