diff --git a/README.md b/README.md index e664a772..bd7b1703 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ summary: all planned features work! now please enjoy the bloatening * ☑ images using Pillow * ☑ videos using FFmpeg * ☑ cache eviction (max-age; maybe max-size eventually) + * ☑ image gallery * ☑ SPA (browse while uploading) * if you use the file-tree on the left only, not folders in the file list * server indexing diff --git a/copyparty/web/baguettebox.js b/copyparty/web/baguettebox.js new file mode 100644 index 00000000..907daff2 --- /dev/null +++ b/copyparty/web/baguettebox.js @@ -0,0 +1,583 @@ +/*! + * baguetteBox.js + * @author feimosi + * @version 1.11.1-mod + * @url https://github.com/feimosi/baguetteBox.js + */ + +window.baguetteBox = (function () { + 'use strict'; + + var options = {}, + defaults = { + captions: true, + buttons: 'auto', + noScrollbars: false, + bodyClass: 'baguetteBox-open', + titleTag: false, + async: false, + preload: 2, + animation: 'slideIn', + afterShow: null, + afterHide: null, + onChange: null, + }, + overlay, slider, previousButton, nextButton, closeButton, + currentGallery = [], + currentIndex = 0, + isOverlayVisible = false, + touch = {}, // start-pos + touchFlag = false, // busy + regex = /.+\.(gif|jpe?g|png|webp)/i, + data = {}, // all galleries + imagesElements = [], + documentLastFocus = null; + + var overlayClickHandler = function (event) { + if (event.target.id.indexOf('baguette-img') !== -1) { + hideOverlay(); + } + }; + + var touchstartHandler = function (event) { + touch.count++; + if (touch.count > 1) { + touch.multitouch = true; + } + touch.startX = event.changedTouches[0].pageX; + touch.startY = event.changedTouches[0].pageY; + }; + var touchmoveHandler = function (event) { + if (touchFlag || touch.multitouch) { + return; + } + event.preventDefault ? event.preventDefault() : event.returnValue = false; + var touchEvent = event.touches[0] || event.changedTouches[0]; + if (touchEvent.pageX - touch.startX > 40) { + touchFlag = true; + showPreviousImage(); + } else if (touchEvent.pageX - touch.startX < -40) { + touchFlag = true; + showNextImage(); + } else if (touch.startY - touchEvent.pageY > 100) { + hideOverlay(); + } + }; + var touchendHandler = function () { + touch.count--; + if (touch.count <= 0) { + touch.multitouch = false; + } + touchFlag = false; + }; + var contextmenuHandler = function () { + touchendHandler(); + }; + + var trapFocusInsideOverlay = function (event) { + if (overlay.style.display === 'block' && (overlay.contains && !overlay.contains(event.target))) { + event.stopPropagation(); + initFocus(); + } + }; + + function run(selector, userOptions) { + buildOverlay(); + removeFromCache(selector); + return bindImageClickListeners(selector, userOptions); + } + + function bindImageClickListeners(selector, userOptions) { + var galleryNodeList = document.querySelectorAll(selector); + var selectorData = { + galleries: [], + nodeList: galleryNodeList + }; + data[selector] = selectorData; + + [].forEach.call(galleryNodeList, function (galleryElement) { + if (userOptions && userOptions.filter) { + regex = userOptions.filter; + } + + var tagsNodeList = []; + if (galleryElement.tagName === 'A') { + tagsNodeList = [galleryElement]; + } else { + tagsNodeList = galleryElement.getElementsByTagName('a'); + } + + tagsNodeList = [].filter.call(tagsNodeList, function (element) { + if (element.className.indexOf(userOptions && userOptions.ignoreClass) === -1) { + return regex.test(element.href); + } + }); + if (tagsNodeList.length === 0) { + return; + } + + var gallery = []; + [].forEach.call(tagsNodeList, function (imageElement, imageIndex) { + var imageElementClickHandler = function (event) { + if (event && event.ctrlKey) + return true; + + event.preventDefault ? event.preventDefault() : event.returnValue = false; + prepareOverlay(gallery, userOptions); + showOverlay(imageIndex); + }; + var imageItem = { + eventHandler: imageElementClickHandler, + imageElement: imageElement + }; + bind(imageElement, 'click', imageElementClickHandler); + gallery.push(imageItem); + }); + selectorData.galleries.push(gallery); + }); + + return selectorData.galleries; + } + + function clearCachedData() { + for (var selector in data) { + if (data.hasOwnProperty(selector)) { + removeFromCache(selector); + } + } + } + + function removeFromCache(selector) { + if (!data.hasOwnProperty(selector)) { + return; + } + var galleries = data[selector].galleries; + [].forEach.call(galleries, function (gallery) { + [].forEach.call(gallery, function (imageItem) { + unbind(imageItem.imageElement, 'click', imageItem.eventHandler); + }); + + if (currentGallery === gallery) { + currentGallery = []; + } + }); + + delete data[selector]; + } + + function buildOverlay() { + overlay = ebi('baguetteBox-overlay'); + if (overlay) { + slider = ebi('baguetteBox-slider'); + previousButton = ebi('previous-button'); + nextButton = ebi('next-button'); + closeButton = ebi('close-button'); + return; + } + overlay = mknod('div'); + overlay.setAttribute('role', 'dialog'); + overlay.id = 'baguetteBox-overlay'; + document.getElementsByTagName('body')[0].appendChild(overlay); + + slider = mknod('div'); + slider.id = 'baguetteBox-slider'; + overlay.appendChild(slider); + + previousButton = mknod('button'); + previousButton.setAttribute('type', 'button'); + previousButton.id = 'previous-button'; + previousButton.setAttribute('aria-label', 'Previous'); + previousButton.innerHTML = '<'; + overlay.appendChild(previousButton); + + nextButton = mknod('button'); + nextButton.setAttribute('type', 'button'); + nextButton.id = 'next-button'; + nextButton.setAttribute('aria-label', 'Next'); + nextButton.innerHTML = '>'; + overlay.appendChild(nextButton); + + closeButton = mknod('button'); + closeButton.setAttribute('type', 'button'); + closeButton.id = 'close-button'; + closeButton.setAttribute('aria-label', 'Close'); + closeButton.innerHTML = '×'; + overlay.appendChild(closeButton); + + previousButton.className = nextButton.className = closeButton.className = 'baguetteBox-button'; + + bindEvents(); + } + + function keyDownHandler(event) { + switch (event.keyCode) { + case 37: // Left + showPreviousImage(); + break; + case 39: // Right + showNextImage(); + break; + case 27: // Esc + hideOverlay(); + break; + case 36: // Home + showFirstImage(event); + break; + case 35: // End + showLastImage(event); + break; + } + } + + var passiveSupp = false; + try { + var opts = { + get passive() { + passiveSupp = true; + return false; + } + }; + window.addEventListener('test', null, opts); + window.removeEventListener('test', null, opts); + } + catch (ex) { + passiveSupp = false; + } + var passiveEvent = passiveSupp ? { passive: false } : null; + var nonPassiveEvent = passiveSupp ? { passive: true } : null; + + function bindEvents() { + bind(overlay, 'click', overlayClickHandler); + bind(previousButton, 'click', showPreviousImage); + bind(nextButton, 'click', showNextImage); + bind(closeButton, 'click', hideOverlay); + bind(slider, 'contextmenu', contextmenuHandler); + bind(overlay, 'touchstart', touchstartHandler, nonPassiveEvent); + bind(overlay, 'touchmove', touchmoveHandler, passiveEvent); + bind(overlay, 'touchend', touchendHandler); + bind(document, 'focus', trapFocusInsideOverlay, true); + } + + function unbindEvents() { + unbind(overlay, 'click', overlayClickHandler); + unbind(previousButton, 'click', showPreviousImage); + unbind(nextButton, 'click', showNextImage); + unbind(closeButton, 'click', hideOverlay); + unbind(slider, 'contextmenu', contextmenuHandler); + unbind(overlay, 'touchstart', touchstartHandler, nonPassiveEvent); + unbind(overlay, 'touchmove', touchmoveHandler, passiveEvent); + unbind(overlay, 'touchend', touchendHandler); + unbind(document, 'focus', trapFocusInsideOverlay, true); + } + + function prepareOverlay(gallery, userOptions) { + if (currentGallery === gallery) { + return; + } + currentGallery = gallery; + setOptions(userOptions); + slider.innerHTML = ''; + imagesElements.length = 0; + + var imagesFiguresIds = []; + var imagesCaptionsIds = []; + for (var i = 0, fullImage; i < gallery.length; i++) { + fullImage = mknod('div'); + fullImage.className = 'full-image'; + fullImage.id = 'baguette-img-' + i; + imagesElements.push(fullImage); + + imagesFiguresIds.push('baguetteBox-figure-' + i); + imagesCaptionsIds.push('baguetteBox-figcaption-' + i); + slider.appendChild(imagesElements[i]); + } + overlay.setAttribute('aria-labelledby', imagesFiguresIds.join(' ')); + overlay.setAttribute('aria-describedby', imagesCaptionsIds.join(' ')); + } + + function setOptions(newOptions) { + if (!newOptions) { + newOptions = {}; + } + for (var item in defaults) { + options[item] = defaults[item]; + if (typeof newOptions[item] !== 'undefined') { + options[item] = newOptions[item]; + } + } + slider.style.transition = (options.animation === 'fadeIn' ? 'opacity .4s ease' : + options.animation === 'slideIn' ? '' : 'none'); + + if (options.buttons === 'auto' && ('ontouchstart' in window || currentGallery.length === 1)) { + options.buttons = false; + } + + previousButton.style.display = nextButton.style.display = (options.buttons ? '' : 'none'); + } + + function showOverlay(chosenImageIndex) { + if (options.noScrollbars) { + document.documentElement.style.overflowY = 'hidden'; + document.body.style.overflowY = 'scroll'; + } + if (overlay.style.display === 'block') { + return; + } + + bind(document, 'keydown', keyDownHandler); + currentIndex = chosenImageIndex; + touch = { + count: 0, + startX: null, + startY: null + }; + loadImage(currentIndex, function () { + preloadNext(currentIndex); + preloadPrev(currentIndex); + }); + + updateOffset(); + overlay.style.display = 'block'; + // Fade in overlay + setTimeout(function () { + overlay.className = 'visible'; + if (options.bodyClass && document.body.classList) { + document.body.classList.add(options.bodyClass); + } + if (options.afterShow) { + options.afterShow(); + } + }, 50); + if (options.onChange) { + options.onChange(currentIndex, imagesElements.length); + } + documentLastFocus = document.activeElement; + initFocus(); + isOverlayVisible = true; + } + + function initFocus() { + if (options.buttons) { + previousButton.focus(); + } else { + closeButton.focus(); + } + } + + function hideOverlay(e) { + ev(e); + if (options.noScrollbars) { + document.documentElement.style.overflowY = 'auto'; + document.body.style.overflowY = 'auto'; + } + if (overlay.style.display === 'none') { + return; + } + + unbind(document, 'keydown', keyDownHandler); + // Fade out and hide the overlay + overlay.className = ''; + setTimeout(function () { + overlay.style.display = 'none'; + if (options.bodyClass && document.body.classList) { + document.body.classList.remove(options.bodyClass); + } + if (options.afterHide) { + options.afterHide(); + } + documentLastFocus && documentLastFocus.focus(); + isOverlayVisible = false; + }, 500); + } + + function loadImage(index, callback) { + var imageContainer = imagesElements[index]; + var galleryItem = currentGallery[index]; + + if (typeof imageContainer === 'undefined' || typeof galleryItem === 'undefined') { + return; // out-of-bounds or gallery dirty + } + + if (imageContainer.getElementsByTagName('img')[0]) { + // image is loaded, cb and bail + if (callback) { + callback(); + } + return; + } + + var imageElement = galleryItem.imageElement, + imageSrc = imageElement.href, + thumbnailElement = imageElement.getElementsByTagName('img')[0], + imageCaption = typeof options.captions === 'function' ? + options.captions.call(currentGallery, imageElement) : + imageElement.getAttribute('data-caption') || imageElement.title; + + var figure = mknod('figure'); + figure.id = 'baguetteBox-figure-' + index; + figure.innerHTML = '
' + + '
' + + '
' + + '
'; + + if (options.captions && imageCaption) { + var figcaption = mknod('figcaption'); + figcaption.id = 'baguetteBox-figcaption-' + index; + figcaption.innerHTML = imageCaption; + figure.appendChild(figcaption); + } + imageContainer.appendChild(figure); + + var image = mknod('img'); + image.onload = function () { + // Remove loader element + var spinner = document.querySelector('#baguette-img-' + index + ' .baguetteBox-spinner'); + figure.removeChild(spinner); + if (!options.async && callback) { + callback(); + } + }; + image.setAttribute('src', imageSrc); + image.alt = thumbnailElement ? thumbnailElement.alt || '' : ''; + if (options.titleTag && imageCaption) { + image.title = imageCaption; + } + figure.appendChild(image); + + if (options.async && callback) { + callback(); + } + } + + function showNextImage(e) { + ev(e); + return show(currentIndex + 1); + } + + function showPreviousImage(e) { + ev(e); + return show(currentIndex - 1); + } + + function showFirstImage(event) { + if (event) { + event.preventDefault(); + } + return show(0); + } + + function showLastImage(event) { + if (event) { + event.preventDefault(); + } + return show(currentGallery.length - 1); + } + + /** + * Move the gallery to a specific index + * @param `index` {number} - the position of the image + * @param `gallery` {array} - gallery which should be opened, if omitted assumes the currently opened one + * @return {boolean} - true on success or false if the index is invalid + */ + function show(index, gallery) { + if (!isOverlayVisible && index >= 0 && index < gallery.length) { + prepareOverlay(gallery, options); + showOverlay(index); + return true; + } + if (index < 0) { + if (options.animation) { + bounceAnimation('left'); + } + return false; + } + if (index >= imagesElements.length) { + if (options.animation) { + bounceAnimation('right'); + } + return false; + } + + currentIndex = index; + loadImage(currentIndex, function () { + preloadNext(currentIndex); + preloadPrev(currentIndex); + }); + updateOffset(); + + if (options.onChange) { + options.onChange(currentIndex, imagesElements.length); + } + + return true; + } + + /** + * Triggers the bounce animation + * @param {('left'|'right')} direction - Direction of the movement + */ + function bounceAnimation(direction) { + slider.className = 'bounce-from-' + direction; + setTimeout(function () { + slider.className = ''; + }, 400); + } + + function updateOffset() { + var offset = -currentIndex * 100 + '%'; + if (options.animation === 'fadeIn') { + slider.style.opacity = 0; + setTimeout(function () { + slider.style.transform = 'translate3d(' + offset + ',0,0)'; + slider.style.opacity = 1; + }, 400); + } else { + slider.style.transform = 'translate3d(' + offset + ',0,0)'; + } + } + + function preloadNext(index) { + if (index - currentIndex >= options.preload) { + return; + } + loadImage(index + 1, function () { + preloadNext(index + 1); + }); + } + + function preloadPrev(index) { + if (currentIndex - index >= options.preload) { + return; + } + loadImage(index - 1, function () { + preloadPrev(index - 1); + }); + } + + function bind(element, event, callback, options) { + element.addEventListener(event, callback, options); + } + + function unbind(element, event, callback, options) { + element.removeEventListener(event, callback, options); + } + + function destroyPlugin() { + unbindEvents(); + clearCachedData(); + unbind(document, 'keydown', keyDownHandler); + document.getElementsByTagName('body')[0].removeChild(ebi('baguetteBox-overlay')); + data = {}; + currentGallery = []; + currentIndex = 0; + } + + return { + run: run, + show: show, + showNext: showNextImage, + showPrevious: showPreviousImage, + hide: hideOverlay, + destroy: destroyPlugin + }; +})(); diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css index 11cfeccb..ea46f113 100644 --- a/copyparty/web/browser.css +++ b/copyparty/web/browser.css @@ -750,9 +750,12 @@ input[type="checkbox"]:checked+label { font-family: monospace, monospace; line-height: 2em; } -#griden.on+#thumbs { +#thumbs { opacity: .3; } +#griden.on+#thumbs { + opacity: 1; +} #ghead { background: #3c3c3c; border: 1px solid #444; @@ -802,7 +805,7 @@ html.light #ghead { line-height: 0; font-size: 2em; display: inline-block; - margin: -.7em 0 -.5em -.3em; + margin: -.7em .1em -.5em -.3em; } #ggrid a:hover { background: #444; @@ -1028,4 +1031,161 @@ html.light #tree::-webkit-scrollbar { } #tree::-webkit-scrollbar-thumb { background: #da0; -} \ No newline at end of file +} + + + +#baguetteBox-overlay { + display: none; + opacity: 0; + position: fixed; + overflow: hidden; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000000; + background: rgba(0, 0, 0, 0.8); + transition: opacity .3s ease; +} +#baguetteBox-overlay.visible { + opacity: 1; +} +#baguetteBox-overlay .full-image { + display: inline-block; + position: relative; + width: 100%; + height: 100%; + text-align: center; +} +#baguetteBox-overlay .full-image figure { + display: inline; + margin: 0; + height: 100%; +} +#baguetteBox-overlay .full-image img { + display: inline-block; + width: auto; + height: auto; + max-height: 100%; + max-width: 100%; + vertical-align: middle; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.6); +} +#baguetteBox-overlay .full-image figcaption { + display: block; + position: absolute; + bottom: 0; + width: 100%; + text-align: center; + line-height: 1.8; + white-space: normal; + color: #ccc; +} +#baguetteBox-overlay figcaption a { + background: rgba(0, 0, 0, 0.6); + border-radius: .4em; + padding: .3em .6em; +} +#baguetteBox-overlay .full-image:before { + content: ""; + display: inline-block; + height: 50%; + width: 1px; + margin-right: -1px; +} +#baguetteBox-slider { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + white-space: nowrap; + transition: left .2s ease, transform .2s ease; +} +#baguetteBox-slider.bounce-from-right { + animation: bounceFromRight .4s ease-out; +} +#baguetteBox-slider.bounce-from-left { + animation: bounceFromLeft .4s ease-out; +} +@keyframes bounceFromRight { + 0% {margin-left: 0} + 50% {margin-left: -30px} + 100% {margin-left: 0} +} +@keyframes bounceFromLeft { + 0% {margin-left: 0} + 50% {margin-left: 30px} + 100% {margin-left: 0} +} +.baguetteBox-button#next-button, +.baguetteBox-button#previous-button { + top: 50%; + top: calc(50% - 30px); + width: 44px; + height: 60px; +} +.baguetteBox-button { + position: absolute; + cursor: pointer; + outline: none; + padding: 0; + margin: 0; + border: 0; + border-radius: 15%; + background: rgba(50, 50, 50, 0.5); + color: #ddd; + font: 1.6em sans-serif; + transition: background-color .3s ease; +} +.baguetteBox-button:focus, +.baguetteBox-button:hover { + background: rgba(50, 50, 50, 0.9); +} +#next-button { + right: 2%; +} +#previous-button { + left: 2%; +} +#close-button { + top: 20px; + right: 2%; + width: 30px; + height: 30px; +} +.baguetteBox-button svg { + position: absolute; + left: 0; + top: 0; +} +.baguetteBox-spinner { + width: 40px; + height: 40px; + display: inline-block; + position: absolute; + top: 50%; + left: 50%; + margin-top: -20px; + margin-left: -20px; +} +.baguetteBox-double-bounce1, +.baguetteBox-double-bounce2 { + width: 100%; + height: 100%; + border-radius: 50%; + background-color: #fff; + opacity: .6; + position: absolute; + top: 0; + left: 0; + animation: bounce 2s infinite ease-in-out; +} +.baguetteBox-double-bounce2 { + animation-delay: -1s; +} +@keyframes bounce { + 0%, 100% {transform: scale(0)} + 50% {transform: scale(1)} +} diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 4329cd32..a97afb78 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -709,8 +709,9 @@ function autoplay_blocked(seek) { var thegrid = (function () { - var lfiles = ebi('files'); - var gfiles = document.createElement('div'); + var lfiles = ebi('files'), + gfiles = document.createElement('div'); + gfiles.setAttribute('id', 'gfiles'); gfiles.style.display = 'none'; gfiles.innerHTML = ( @@ -732,7 +733,8 @@ var thegrid = (function () { 'en': bcfg_get('griden', false), 'sel': bcfg_get('gridsel', false), 'sz': fcfg_get('gridsz', 10), - 'isdirty': true + 'isdirty': true, + 'bbox': null }; ebi('thumbs').onclick = function (e) { @@ -891,9 +893,37 @@ var thegrid = (function () { lfiles.style.display = 'none'; gfiles.style.display = 'block'; ebi('ggrid').innerHTML = html.join('\n'); + r.bagit(); r.loadsel(); } + r.bagit = function () { + if (!window.baguetteBox) + return; + + if (r.bbox) + baguetteBox.destroy(); + + r.bbox = baguetteBox.run('#ggrid', { + captions: function (g) { + var idx = -1, + h = '' + g; + + for (var a = 0; a < r.bbox.length; a++) + if (r.bbox[a].imageElement == g) + idx = a; + + return '' + (idx + 1) + ' / ' + r.bbox.length + ' -- ' + + esc(uricom_dec(h.split('/').slice(-1)[0])[0]) + ''; + } + })[0]; + }; + + setTimeout(function () { + import_js('/.cpr/baguettebox.js', r.bagit); + }, 1); + if (r.en) { loadgrid(); } diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh index 2227e507..30229a32 100755 --- a/scripts/make-sfx.sh +++ b/scripts/make-sfx.sh @@ -208,7 +208,7 @@ find | grep -E '\.css$' | while IFS= read -r f; do } !/\}$/ {printf "%s",$0;next} 1 - ' <$f | gsed 's/;\}$/}/' >t + ' <$f | sed 's/;\}$/}/' >t tmv "$f" done find | grep -E '\.(js|html)$' | while IFS= read -r f; do @@ -223,7 +223,7 @@ command -v pigz && pk='gzip' echo "$pk" -find | grep -E '\.(js|css)$' | while IFS= read -r f; do +find | grep -E '\.(js|css)$' | grep -vF /deps/ | while IFS= read -r f; do echo -n . $pk "$f" done diff --git a/tests/test_httpcli.py b/tests/test_httpcli.py index 007f00e3..a1ca9ddf 100644 --- a/tests/test_httpcli.py +++ b/tests/test_httpcli.py @@ -39,6 +39,7 @@ class Cfg(Namespace): mte="a", hist=None, no_hash=False, + css_browser=None, **{k: False for k in "e2d e2ds e2dsa e2t e2ts e2tsr".split()} ) diff --git a/tests/test_vfs.py b/tests/test_vfs.py index 81977c2b..31786406 100644 --- a/tests/test_vfs.py +++ b/tests/test_vfs.py @@ -18,7 +18,13 @@ from copyparty import util class Cfg(Namespace): def __init__(self, a=[], v=[], c=None): ex = {k: False for k in "e2d e2ds e2dsa e2t e2ts e2tsr".split()} - ex2 = {"mtp": [], "mte": "a", "hist": None, "no_hash": False} + ex2 = { + "mtp": [], + "mte": "a", + "hist": None, + "no_hash": False, + "css_browser": None, + } ex.update(ex2) super(Cfg, self).__init__(a=a, v=v, c=c, **ex)