From fa6014fefa6028641e64325b5a8a2f45e970e06b Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 20:46:11 +0100 Subject: [PATCH 01/10] Export module for a website gallery with selected images This export module should replace the PhotoSwipe copy in darktable. See https://github.com/darktable-org/darktable/issues/16205 for more details. --- contrib/website_gallery_export.lua | 262 ++++++++++++++++++ data/website_gallery/fullscreen.js | 40 +++ data/website_gallery/gallery.css | 113 ++++++++ data/website_gallery/gallery.js | 113 ++++++++ data/website_gallery/index.html | 45 ++++ data/website_gallery/modal.css | 95 +++++++ data/website_gallery/modal.js | 409 +++++++++++++++++++++++++++++ 7 files changed, 1077 insertions(+) create mode 100644 contrib/website_gallery_export.lua create mode 100644 data/website_gallery/fullscreen.js create mode 100644 data/website_gallery/gallery.css create mode 100644 data/website_gallery/gallery.js create mode 100644 data/website_gallery/index.html create mode 100644 data/website_gallery/modal.css create mode 100644 data/website_gallery/modal.js diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua new file mode 100644 index 00000000..c691b8b5 --- /dev/null +++ b/contrib/website_gallery_export.lua @@ -0,0 +1,262 @@ +--[[Export module to create a web gallery from selected images + + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +]] + +--[[ + TODO: + - before PR: Zoom, export code from wpferguson, use share_dir + - Lua: remove images dir if already existent + - Lua: implement "supported" callback to limit export to suited file formats + - Lua: translations +]] + +local dt = require "darktable" +local df = require "lib/dtutils.file" + +local temp = dt.preferences.read('web_gallery', 'title', 'string') +if temp == nil then temp = 'Darktable gallery' end + +local title_widget = dt.new_widget("entry") +{ + text = temp +} + +local temp = dt.preferences.read('web_gallery', 'destination_dir', 'string') +if temp == nil then temp = '' end + +local dest_dir_widget = dt.new_widget("file_chooser_button") +{ + title = "select output folder", + tooltip = "select output folder", + value = temp, + is_directory = true, + changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end +} + +local gallery_widget = dt.new_widget("box") +{ + orientation=vertical, + dt.new_widget("label"){label = "gallery title"}, + title_widget, + dt.new_widget("label"){label = "destination directory"}, + dest_dir_widget +} + +local function get_file_name(file) + return file:match("[^/]*.$") +end + +function escape_js_string(str) + local replacements = { + ['\\'] = '\\\\', + ['"'] = '\\"', + ["'"] = "\\'", + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\v'] = '\\v' + } + return (str:gsub('[\\\"\n\r\t\b\f\v\']', replacements)) +end + +local function export_thumbnail(image, filename) + dt.print("export thumbnail image "..filename) + exporter = dt.new_format("jpeg") + exporter.quality = 90 + exporter.max_height = 512 + exporter.max_width = 512 + exporter:write_image(image, filename, true) +end + +local function write_image(image, dest_dir, filename) + df.file_move(filename, dest_dir.."/"..get_file_name(filename)) + export_thumbnail(image, dest_dir.."/thumb_"..get_file_name(filename)) +end + +function exiftool_get_image_dimensions(filename) + local handle = io.popen("exiftool " .. filename) + local result = handle:read("*a") + handle:close() + for line in result:gmatch("[^\r\n]+") do + local w = line:match("^Image Width%s*:%s*(%d+)") + if w then + width = tonumber(w) + end + local h = line:match("^Image Height%s*:%s*(%d+)") + if h then + height = tonumber(h) + end + end + if width and height then + return width, height + else + return nil, nil + end +end + +local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) + dest_dir = dest_dir.."/images" + local gallery_data = { name = escape_js_string(title) } + + local images = {} + local index = 1 + for i, image in pairs(images_ordered) do + local filename = images_table[image] + write_image(image, dest_dir, filename) + + if exiftool then + width, height = exiftool_get_image_dimensions(dest_dir.."/"..get_file_name(filename)) + else + width = sizes[index].width + height = sizes[index].height + end + + local entry = { filename = "images/"..get_file_name(escape_js_string(filename)), + width = width, height = height } + + images[index] = entry + index = index + 1 + end + + gallery_data.images = images + return gallery_data +end + +local function generate_javascript_gallery_object(gallery) + local js = 'const gallery_data = {\n' + js = js .. ' name: "' .. gallery.name .. '",\n' + js = js .. ' images: [\n' + + for i, img in ipairs(gallery.images) do + js = js .. string.format(' { filename: "%s",\n height: %d,\n width: %d }', img.filename, img.height, img.width) + if i < #gallery.images then + js = js .. ',\n' + else + js = js .. '\n' + end + end + + js = js .. ' ]\n};\n' + + return(js) +end + +local function write_javascript_file(gallery_table, dest_dir) + dt.print("write JavaScript file") + javascript_object = generate_javascript_gallery_object(gallery_table) + + local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') + if fileOut then + fileOut:write(javascript_object) + else + log.msg(log.error, errr) + end + fileOut:close() +end + +local function copy_static_files(dest_dir) + dt.print("copy static gallery files") + gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" + gfiles = { + "index.html", + "gallery.css", + "modal.css", + "modal.js", + "gallery.js", + "fullscreen.js" + } + + for _, file in ipairs(gfiles) do + df.file_copy(gfsrc.."/"..file, dest_dir.."/"..file) + end +end + +local function build_gallery(storage, images_table, extra_data) + local dest_dir = dest_dir_widget.value + df.mkdir(dest_dir) + df.mkdir(dest_dir.."/images") + + local images_ordered = extra_data["images"] -- process images in the correct order + local sizes = extra_data["sizes"] + local title = "Darktable export" + if title_widget.text ~= "" then + title = title_widget.text + end + local exiftool = df.check_if_bin_exists("exiftool"); + gallerydata = fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) + write_javascript_file(gallerydata, dest_dir) + copy_static_files(dest_dir) +end + +local script_data = {} + +script_data.metadata = { + name = "website gallery (new)", + purpose = "create a web gallery from exported images", + author = "Tino Mettler ", + help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/TODO" +} + +script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil +script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again +script_data.show = nil -- only required for libs since the destroy_method only hides them + +local function destroy() + dt.preferences.write('web_gallery', 'title', 'string', title_widget.text) + dt.destroy_storage("module_webgallery") +end +script_data.destroy = destroy + +local function show_status(storage, image, format, filename, + number, total, high_quality, extra_data) + dt.print(string.format("export image %i/%i", number, total)) + aspect = image.aspect_ratio + -- calculate the size of the exported image and store it in extra_data + -- to make it available in the finalize function + if image.final_height == 0 then + if aspect < 1 then + dimensions = { width = image.height, height = image.width } + else + dimensions = { width = image.width, height = image.height } + end + else + dimensions = { width = image.final_width, height = image.final_height } + end + if format.max_height > 0 and dimensions.height > format.max_height then + scale = format.max_height / dimensions.height + dimensions.height = math.floor(dimensions.height * scale + 0.5) + dimensions.width = math.floor(dimensions.width * scale + 0.5) + end + if format.max_width > 0 and dimensions.width > format.max_width then + scale = format.max_width / dimensions.width + dimensions.height = math.floor(dimensions.height * scale + 0.5) + dimensions.width = math.floor(dimensions.width * scale + 0.5) + end + extra_data["sizes"][number] = dimensions +end + +local function initialize(storage, img_format, images, high_quality, extra_data) + dt.preferences.write('web_gallery', 'title', 'string', title_widget.text) + extra_data["images"] = images -- needed, to preserve images order + extra_data["sizes"] = {}; +end + +dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, nil, initialize, gallery_widget) + +return script_data diff --git a/data/website_gallery/fullscreen.js b/data/website_gallery/fullscreen.js new file mode 100644 index 00000000..c14b88cd --- /dev/null +++ b/data/website_gallery/fullscreen.js @@ -0,0 +1,40 @@ +var isFullscreen = false; + +var toggleFullscreen = function (ele) { + return isFullscreen ? exitFullscreen(ele) : requestFullscreen(ele); +}; + +var requestFullscreen = function (ele) { + if(isFullscreen == true) return 0 ; + + isFullscreen = true; + if (ele.requestFullscreen) { + ele.requestFullscreen(); + } else if (ele.webkitRequestFullscreen) { + ele.webkitRequestFullscreen(); + } else if (ele.mozRequestFullScreen) { + ele.mozRequestFullScreen(); + } else if (ele.msRequestFullscreen) { + ele.msRequestFullscreen(); + } else { + console.log('Fullscreen API is not supported.'); + } +}; + +var exitFullscreen = function () { + if(isFullscreen == false) return 0; + + isFullscreen = false; + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else { + console.log('Fullscreen API is not supported.'); + } +}; + diff --git a/data/website_gallery/gallery.css b/data/website_gallery/gallery.css new file mode 100644 index 00000000..5d05a0e1 --- /dev/null +++ b/data/website_gallery/gallery.css @@ -0,0 +1,113 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + +body { + font-family: system-ui, Arial, Helvetica, sans-serif; + background-color: rgb(238, 240, 242); +} + +.heading h1 { + text-align: center; + background-color: #cdd; + color: black; + display: grid; +} + +.heading h1:after { + content: " "; + border-bottom: 1px solid #888888; + height: 1px; +} + +.gallery { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 3vw; + margin: 3vw auto; + padding: 1.5vw; +} + +.slider { + margin: 0; + padding: 0; +} + + +.gallery img { + cursor: pointer; + object-fit: contain; + background: none; + padding: 0; + border: 1px outset #737780; + +} + +.gallery img:hover { + transform: scale(1.08); +} + +.thumb { + object-fit: contain; + flex-shrink: 0; + min-width: 100%; + min-height: 100%; + max-width: 100%; + max-height: 100%; +} + +.thumb-box { + display: flex; + align-items: center; + object-fit: contain; + justify-content: center; + background: none; + box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6); +} + +.navgrid { + font-size: 3.5vh; + display: grid; + grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em; + grid-column-gap: 1vw; + background-color: black; +} + +.nav { + height: 5vh; + background-color: rgba(60,60,60, 0.4); + justify-content: center; + align-items: center; + display: flex; + z-index: 1001; + color: #ccc; +} + +.counter { + font-size: 2.5vh; +} + +.button { + cursor: pointer; +} + +.button:hover { + cursor: pointer; + color: white; + text-shadow: 0px 0px 10px #ccc; +} diff --git a/data/website_gallery/gallery.js b/data/website_gallery/gallery.js new file mode 100644 index 00000000..54b0c875 --- /dev/null +++ b/data/website_gallery/gallery.js @@ -0,0 +1,113 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + +var scrollPosX = 0; +var scrollPosY = 0; + +document.addEventListener('DOMContentLoaded', function () { + var imageCount = 0; + const gallery = document.getElementById('gallery'); + const viewer = document.getElementById('viewer'); + + function showModal(e) { + const thumbbox = e.target.parentElement; + const index = [...gallery.children].indexOf(thumbbox); + currentIndex = index; + scrollPosX = document.documentElement.scrollLeft; + scrollPosY = document.documentElement.scrollTop; + + gallery.style.display = 'none'; + document.getElementById('heading1').style.display = 'none'; + viewer.style.display = 'grid'; + loadSlides(); + updateCounter(currentIndex); + updateNavigationState(); + } + + + function closeModal() { + exitFullscreen(document.documentElement); + viewer.style.display = 'none'; + document.getElementById('heading1').style.display = 'grid'; + gallery.style.display = 'flex'; + document.documentElement.scrollTo({ + left: scrollPosX, + top: scrollPosY, + behavior: "instant", + }); + }; + + function createThumbnailElement(imageObj) { + const frame = document.createElement('div'); + frame.className = 'thumb-box'; + const framesize = 18; + + const width = parseInt(imageObj.width); + const height = parseInt(imageObj.height); + const aspect = height / width; + const sum = width + height; + const scalefactor = sum / (framesize * 2.0); + frame.style.width = (width / scalefactor) + 'vw'; + frame.style.height = (height / scalefactor) + 'vw'; + + const img = document.createElement('img'); + img.className = 'thumb'; + img.src = imageObj.filename.replace(/images\/(.*)$/i, 'images/thumb_$1'); + img.alt = imageObj.filename; + img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); }); + + frame.appendChild(img); + gallery.appendChild(frame); + } + + const images = gallery_data.images; + + const title = document.getElementById('gallery-title'); + const pageTitle = document.getElementById('page-title'); + if (gallery_data.name) { + title.textContent = gallery_data.name; + pageTitle.textContent = gallery_data.name; + } + + + document.getElementById('close').onclick = function (e) { + e.stopPropagation(); + closeModal(); + }; + + // Keyboard navigation using left/right arrow keys + document.onkeyup = function (e) { + e.stopPropagation(); + switch(e.key) { + case "Escape": + closeModal(); + break; + } + }; + + document.getElementById('fullscreen').onclick = function (e) { + e.stopPropagation(); + toggleFullscreen(document.documentElement); + }; + + // Populate thumbnail gallery + images.forEach(function (imageObj) { + createThumbnailElement(imageObj); + }); + + +}); diff --git a/data/website_gallery/index.html b/data/website_gallery/index.html new file mode 100644 index 00000000..e9ea6140 --- /dev/null +++ b/data/website_gallery/index.html @@ -0,0 +1,45 @@ + + + + + + + + + Image Gallery + + + + + +
+

Image Gallery

+
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/data/website_gallery/modal.css b/data/website_gallery/modal.css new file mode 100644 index 00000000..e7c620da --- /dev/null +++ b/data/website_gallery/modal.css @@ -0,0 +1,95 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; + background: #000; + display: flex; + flex-direction: column; +} + +.viewer { + position: relative; + width: 100vw; + height: 100vh; + touch-action: none; + overflow: hidden; + display: none; +} + +.slider { + position: relative; + width: 100vw; + height: 95vh; + touch-action: none; + overflow: hidden; +} + +.slide-container { + position: absolute; + top: 0; + left: 0; + width: 300%; + height: 100%; + display: flex; + transform: translateX(-33.333%); + transition: transform 0.3s ease-out; + will-change: transform; +} + +.slide { + width: 33.333%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: #000; + will-change: contents; + overflow: hidden; + position: relative; + padding: 0; + margin: 0; + touch-action: none; + object-fit: contain; +} + +.slide img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + padding: 0; + margin: 0; + display: block; + transform-origin: 0 0; + cursor: zoom-in; + object-fit: contain; +} + +.slide.zoomed img { + cursor: zoom-out; + max-width: none; + max-height: none; +} + +.slide img.zooming { + transition: transform 0.2s ease-out; +} + +.nav-bar { + width: 100vw; + height: 5vh; + background: #333; + color: #fff; + display: flex; + align-items: center; + padding-left: 20px; + box-sizing: border-box; + font-family: Arial, sans-serif; + font-size: 16px; +} diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js new file mode 100644 index 00000000..7258f185 --- /dev/null +++ b/data/website_gallery/modal.js @@ -0,0 +1,409 @@ + +// DOM Elements +const container = document.querySelector('.slide-container'); +const slides = { + prev: document.getElementById('prev'), + current: document.getElementById('current'), + next: document.getElementById('next') +}; +const prevArrow = document.getElementById('prevArrow'); +const nextArrow = document.getElementById('nextArrow'); + +// State management +let mouseTimer = null; +let startX = 0; +let startY = 0; +let isDragging = false; +let isZoomed = false; +let isPanning = false; +let hasPanned = false; +let translateX = 0; +let translateY = 0; +let currentScale = 1; +let minX = 0; +let minY = 0; +let containerRect, imgRect; +let img; + +let baseWidth, baseHeight; // Image dimensions before zoom +let baseOffsetX, baseOffsetY; // Image position offset before zoom (due to centering) + +let scaledWidth, scaledHeight; + +const images = gallery_data.images; + +function updateCounter(index) { + const counter = document.getElementById('counter'); + counter.textContent = (index + 1) + ' / ' + images.length; +} + +function updateBoundaries() { + containerRect = container.getBoundingClientRect(); + // Calculate the actual scaled dimensions based on the base (pre-zoom) size + scaledWidth = baseWidth * currentScale; + scaledHeight = baseHeight * currentScale; + console.log('Boundaries:', { + containerRect, + scaledWidth, + scaledHeight, + baseWidth, + baseHeight, + currentScale + }); +} + +function limitPanning(proposedX, proposedY) { + // With transform-origin: 0 0 and transform: translate(tx, ty) scale(s) + // + // Before zoom: + // - Image element positioned at (baseOffsetX, baseOffsetY) in container + // - Image size is (baseWidth, baseHeight) + // + // After transform is applied: + // - First, scale happens around origin (0,0) of the element: element becomes (baseWidth*s, baseHeight*s) + // - Then translate by (tx, ty) moves the whole element + // - Final position in viewport: element's top-left is at (baseOffsetX + tx, baseOffsetY + ty) + // - Element's bottom-right is at (baseOffsetX + tx + scaledWidth, baseOffsetY + ty + scaledHeight) + // + // We want the image content edges to stay within the container while allowing original borders: + // - Left constraint: baseOffsetX + tx >= baseOffsetX => tx >= 0 + // - Right constraint: baseOffsetX + tx + scaledWidth <= containerWidth - (containerWidth - baseOffsetX - baseWidth) + // baseOffsetX + tx + scaledWidth <= baseOffsetX + baseWidth + // tx <= baseWidth - scaledWidth + // - Top constraint: baseOffsetY + ty >= baseOffsetY => ty >= 0 + // - Bottom constraint: baseOffsetY + ty + scaledHeight <= baseOffsetY + baseHeight + // ty <= baseHeight - scaledHeight + + // Calculate limits + const maxX = 0; + const minX = baseWidth - scaledWidth; + + const maxY = 0; + const minY = baseHeight - scaledHeight; + + let constrainedX = proposedX; + let constrainedY = proposedY; + + // Apply constraints + if (scaledWidth > baseWidth) { + // Image wider than original - constrain panning + constrainedX = Math.max(minX, Math.min(maxX, proposedX)); + } else { + // Image narrower than original - center it + constrainedX = (baseWidth - scaledWidth) / 2; + } + + if (scaledHeight > baseHeight) { + // Image taller than original - constrain panning + constrainedY = Math.max(minY, Math.min(maxY, proposedY)); + } else { + // Image shorter than original - center it + constrainedY = (baseHeight - scaledHeight) / 2; + } + + console.log('limitPanning:', { + proposed: { x: proposedX, y: proposedY }, + constrained: { x: constrainedX, y: constrainedY }, + limits: { minX, maxX, minY, maxY }, + baseOffset: { x: baseOffsetX, y: baseOffsetY }, + baseSize: { w: baseWidth, h: baseHeight }, + scaledSize: { w: scaledWidth, h: scaledHeight } + }); + + return { + x: constrainedX, + y: constrainedY + }; +} + +function handleZoom(e) { + // If we were actually panning, don't zoom + if (hasPanned) { + isPanning = false; + hasPanned = false; + return; + } + + // If we're zoomed and haven't panned, zoom out + if (isZoomed) { + img.classList.add('zooming'); + translateX = 0; + translateY = 0; + currentScale = 1; + img.style.transform = 'none'; + container.classList.remove('zoomed'); + isZoomed = false; + updateNavigationState(); + + setTimeout(() => { + img.classList.remove('zooming'); + }, 200); + return; + } + + // Zoom in at clicked/tapped point + if (!isZoomed) { + const rect = img.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Store the base (pre-zoom) dimensions and offset + baseWidth = rect.width; + baseHeight = rect.height; + baseOffsetX = rect.left; + baseOffsetY = rect.top; + + // Calculate the scale for 1:1 pixel zoom + currentScale = img.naturalWidth / rect.width; + + // Calculate the translation needed to keep clicked point under cursor + // After scaling, the point at (x, y) will be at (x * currentScale, y * currentScale) + // We want it to remain at (e.clientX - rect.left, e.clientY - rect.top) + translateX = e.clientX - rect.left - (x * currentScale); + translateY = e.clientY - rect.top - (y * currentScale); + + updateBoundaries(); + const limited = limitPanning(translateX, translateY); + translateX = limited.x; + translateY = limited.y; + + img.classList.add('zooming'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + container.classList.add('zoomed'); + + setTimeout(() => { + img.classList.remove('zooming'); + }, 200); + } + isZoomed = true; + updateNavigationState(); +} + +function createImageElement(imageData) { + if (!imageData) return null; + const img = new Image(); + img.src = imageData.filename; + img.width = imageData.width; + img.height = imageData.height; + img.addEventListener('dragstart', (e) => e.preventDefault()); + return img; +} + +function loadSlides() { + slides.prev.innerHTML = ''; + slides.current.innerHTML = ''; + slides.next.innerHTML = ''; + + if (currentIndex > 0) { + const prevImg = createImageElement(images[currentIndex - 1]); + if (prevImg) slides.prev.appendChild(prevImg); + } + + const currentImg = createImageElement(images[currentIndex]); + if (currentImg) { + img = currentImg; + slides.current.appendChild(currentImg); + } + + if (currentIndex < images.length - 1) { + const nextImg = createImageElement(images[currentIndex + 1]); + if (nextImg) slides.next.appendChild(nextImg); + } + + updateCounter(currentIndex) + updateNavigationState(); +} + +function updateNavigationState() { + prevArrow.style.visibility = (currentIndex === 0 || isZoomed) ? 'hidden' : 'visible'; + nextArrow.style.visibility = (currentIndex === images.length - 1 || isZoomed) ? 'hidden' : 'visible'; +} + +async function showPreviousImage() { + if (currentIndex > 0 && !isZoomed) { + currentIndex--; + container.style.transition = 'transform 0.3s ease-out'; + container.style.transform = 'translateX(0%)'; + await waitForTransition(); + container.style.transition = 'none'; + container.style.transform = 'translateX(-33.333%)'; + loadSlides(); + } +} + +async function showNextImage() { + if (currentIndex < images.length - 1 && !isZoomed) { + currentIndex++; + container.style.transition = 'transform 0.3s ease-out'; + container.style.transform = 'translateX(-66.666%)'; + await waitForTransition(); + container.style.transition = 'none'; + container.style.transform = 'translateX(-33.333%)'; + loadSlides(); + } +} + +function handleTouchStart(e) { + if (isZoomed) return; + startX = e.touches[0].clientX; + isDragging = true; + container.style.transition = 'none'; +} + +function handleTouchMove(e) { + if (!isDragging || isZoomed) return; + + const currentX = e.touches[0].clientX; + const diff = currentX - startX; + const baseOffset = -33.333; + const percentMoved = (diff / window.innerWidth) * 33.333; + + container.style.transform = `translateX(${baseOffset + percentMoved}%)`; +} + +async function handleTouchEnd(e) { + if (!isDragging) + if(isZoomed) { + handleZoom(e); + return; + } + isDragging = false; + + const endX = e.changedTouches[0].clientX; + const diff = endX - startX; + const threshold = window.innerWidth * 0.2; + + container.style.transition = 'transform 0.3s ease-out'; + + if (diff > threshold && currentIndex > 0) { + await showPreviousImage(); + } else if (diff < -threshold && currentIndex < images.length - 1) { + await showNextImage(); + } else { + container.style.transform = 'translateX(-33.333%)'; + } +} + +function handleMouseMove() { + if (isZoomed) return; + + prevArrow.classList.add('visible'); + nextArrow.classList.add('visible'); + + if (mouseTimer) { + clearTimeout(mouseTimer); + } + + mouseTimer = setTimeout(() => { + prevArrow.classList.remove('visible'); + nextArrow.classList.remove('visible'); + }, 1000); +} + +function handleKeyDown(e) { + if (isZoomed) return; + + if (e.code === 'Space' || e.code === 'ArrowRight') { + e.preventDefault(); + showNextImage(); + } else if (e.code === 'Backspace' || e.code === 'ArrowLeft') { + e.preventDefault(); + showPreviousImage(); + } +} + +function waitForTransition() { + return new Promise(resolve => { + container.addEventListener('transitionend', resolve, { once: true }); + }); +} + +// Initialize +loadSlides(); + +// Navigation event listeners +container.addEventListener('touchstart', handleTouchStart); +container.addEventListener('touchmove', handleTouchMove); +container.addEventListener('touchend', handleTouchEnd); +container.addEventListener('click', handleZoom); + +// make nav arrows visible +document.addEventListener('mousemove', handleMouseMove); + +document.addEventListener('keydown', handleKeyDown); +prevArrow.addEventListener('click', showPreviousImage); +nextArrow.addEventListener('click', showNextImage); + +// Mouse panning event listeners +container.addEventListener('mousedown', function(e) { + if (isZoomed && e.target.tagName === 'IMG') { + isPanning = true; + hasPanned = false; + updateBoundaries(); + startX = e.clientX - translateX; + startY = e.clientY - translateY; + e.preventDefault(); + container.style.cursor = 'grabbing'; + } +}); + +window.addEventListener('mousemove', function(e) { + if (isPanning && isZoomed) { + const proposedX = e.clientX - startX; + const proposedY = e.clientY - startY; + + const limited = limitPanning(proposedX, proposedY); + translateX = limited.x; + translateY = limited.y; + + const img = slides.current.querySelector('img'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + hasPanned = true; + } +}); + +window.addEventListener('mouseup', function() { + if (isPanning) { + isPanning = false; + container.style.cursor = isZoomed ? 'zoom-out' : 'zoom-in'; + } +}); + +// Touch panning event listeners +container.addEventListener('touchstart', function(e) { + if (isZoomed) { + isPanning = true; + hasPanned = false; + updateBoundaries(); + const touch = e.touches[0]; + startX = touch.clientX - translateX; + startY = touch.clientY - translateY; + e.preventDefault(); + } +}); + +container.addEventListener('touchmove', function(e) { + if (isPanning && isZoomed) { + const touch = e.touches[0]; + const proposedX = touch.clientX - startX; + const proposedY = touch.clientY - startY; + + const limited = limitPanning(proposedX, proposedY); + translateX = limited.x; + translateY = limited.y; + + const img = slides.current.querySelector('img'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + hasPanned = true; + e.preventDefault(); + } +}); + +container.addEventListener('touchend', function(e) { + if (isPanning) { + isPanning = false; + if (!hasPanned) { + handleZoomTap(e.changedTouches[0]); + } + } +}); From 12ccefcc3bc6d416fcbdbb24302654eaa0d1fc58 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:31:41 +0100 Subject: [PATCH 02/10] Add missing copyright/license information --- data/website_gallery/modal.css | 17 +++++++++++++++++ data/website_gallery/modal.js | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/data/website_gallery/modal.css b/data/website_gallery/modal.css index e7c620da..aa8ca721 100644 --- a/data/website_gallery/modal.css +++ b/data/website_gallery/modal.css @@ -1,3 +1,20 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + * { margin: 0; padding: 0; diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js index 7258f185..0eeebf1b 100644 --- a/data/website_gallery/modal.js +++ b/data/website_gallery/modal.js @@ -1,5 +1,20 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ -// DOM Elements const container = document.querySelector('.slide-container'); const slides = { prev: document.getElementById('prev'), From 22c9a7cd6ed21b684dab94b6c620594d89658e08 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:32:10 +0100 Subject: [PATCH 03/10] Comments, remove cruft --- data/website_gallery/modal.js | 44 ++--------------------------------- 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js index 0eeebf1b..6e9c29bf 100644 --- a/data/website_gallery/modal.js +++ b/data/website_gallery/modal.js @@ -24,13 +24,12 @@ const slides = { const prevArrow = document.getElementById('prevArrow'); const nextArrow = document.getElementById('nextArrow'); -// State management let mouseTimer = null; let startX = 0; let startY = 0; -let isDragging = false; +let isDragging = false; // For swipe navigation let isZoomed = false; -let isPanning = false; +let isPanning = false; // Panning in zoomed state let hasPanned = false; let translateX = 0; let translateY = 0; @@ -57,14 +56,6 @@ function updateBoundaries() { // Calculate the actual scaled dimensions based on the base (pre-zoom) size scaledWidth = baseWidth * currentScale; scaledHeight = baseHeight * currentScale; - console.log('Boundaries:', { - containerRect, - scaledWidth, - scaledHeight, - baseWidth, - baseHeight, - currentScale - }); } function limitPanning(proposedX, proposedY) { @@ -116,15 +107,6 @@ function limitPanning(proposedX, proposedY) { constrainedY = (baseHeight - scaledHeight) / 2; } - console.log('limitPanning:', { - proposed: { x: proposedX, y: proposedY }, - constrained: { x: constrainedX, y: constrainedY }, - limits: { minX, maxX, minY, maxY }, - baseOffset: { x: baseOffsetX, y: baseOffsetY }, - baseSize: { w: baseWidth, h: baseHeight }, - scaledSize: { w: scaledWidth, h: scaledHeight } - }); - return { x: constrainedX, y: constrainedY @@ -299,22 +281,6 @@ async function handleTouchEnd(e) { } } -function handleMouseMove() { - if (isZoomed) return; - - prevArrow.classList.add('visible'); - nextArrow.classList.add('visible'); - - if (mouseTimer) { - clearTimeout(mouseTimer); - } - - mouseTimer = setTimeout(() => { - prevArrow.classList.remove('visible'); - nextArrow.classList.remove('visible'); - }, 1000); -} - function handleKeyDown(e) { if (isZoomed) return; @@ -342,9 +308,6 @@ container.addEventListener('touchmove', handleTouchMove); container.addEventListener('touchend', handleTouchEnd); container.addEventListener('click', handleZoom); -// make nav arrows visible -document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('keydown', handleKeyDown); prevArrow.addEventListener('click', showPreviousImage); nextArrow.addEventListener('click', showNextImage); @@ -417,8 +380,5 @@ container.addEventListener('touchmove', function(e) { container.addEventListener('touchend', function(e) { if (isPanning) { isPanning = false; - if (!hasPanned) { - handleZoomTap(e.changedTouches[0]); - } } }); From 071a3527c1b000a18ccb8c458bb43e0b2c048cf1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:32:19 +0100 Subject: [PATCH 04/10] Add new website gallery module to README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 86fcfa9d..5b1cd1b7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports [slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow [transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy [video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable +website_gallery_export|No|LMW|Export a website gallery for selected images ### Example Scripts From 7f4bae4234b883360f3c90b8b3fba4f505ef50e2 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 22:31:10 +0100 Subject: [PATCH 05/10] Update TODO --- contrib/website_gallery_export.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index c691b8b5..f05f9497 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -18,7 +18,6 @@ --[[ TODO: - - before PR: Zoom, export code from wpferguson, use share_dir - Lua: remove images dir if already existent - Lua: implement "supported" callback to limit export to suited file formats - Lua: translations From dfeff73fcd7c4b7e8b392af57762c7e0ff4c38a1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 4 Dec 2025 11:47:36 +0100 Subject: [PATCH 06/10] Add callback to check if export format is supported Currently JPG, TIFF, PNG and WebP are supported. --- contrib/website_gallery_export.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index f05f9497..56237f6a 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -256,6 +256,22 @@ local function initialize(storage, img_format, images, high_quality, extra_data) extra_data["sizes"] = {}; end -dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, nil, initialize, gallery_widget) +local supported_formats = { "jpg", "tif", "png", "webp" } + +local formats_lut = {} +for key,format in pairs(supported_formats) do + formats_lut[format] = true +end + +function check_supported(storage, format) + extension = format.extension + if formats_lut[extension] == true then + return true + else + return false + end +end + +dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, check_supported, initialize, gallery_widget) return script_data From 7f00cb8d6c548769b3808974cabf082cf9f0a6d1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 4 Dec 2025 19:09:22 +0100 Subject: [PATCH 07/10] Update TODO list for the website gallery export --- contrib/website_gallery_export.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 56237f6a..866ed556 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -19,7 +19,6 @@ --[[ TODO: - Lua: remove images dir if already existent - - Lua: implement "supported" callback to limit export to suited file formats - Lua: translations ]] From a47de82afde1b7cf80b78a3b25fe73cd9b0d8022 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Mon, 8 Dec 2025 14:53:58 +0100 Subject: [PATCH 08/10] Show progress during thumbnail export --- contrib/website_gallery_export.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 866ed556..843efc92 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -74,7 +74,6 @@ function escape_js_string(str) end local function export_thumbnail(image, filename) - dt.print("export thumbnail image "..filename) exporter = dt.new_format("jpeg") exporter.quality = 90 exporter.max_height = 512 @@ -108,14 +107,21 @@ function exiftool_get_image_dimensions(filename) end end +local function stop_job(job) + job.valid = false +end + local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) dest_dir = dest_dir.."/images" local gallery_data = { name = escape_js_string(title) } local images = {} local index = 1 + local job = dt.gui.create_job("exporting thumbnail images", true, stop_job) + for i, image in pairs(images_ordered) do local filename = images_table[image] + dt.print("exporting thumbnail image "..index.."/"..#images_ordered) write_image(image, dest_dir, filename) if exiftool then @@ -129,9 +135,11 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, width = width, height = height } images[index] = entry + job.percent = index / #images_ordered index = index + 1 end + stop_job(job) gallery_data.images = images return gallery_data end @@ -175,7 +183,7 @@ local function copy_static_files(dest_dir) "index.html", "gallery.css", "modal.css", - "modal.js", + "modal.js", "gallery.js", "fullscreen.js" } From c6002e7eb793c32ff24c40e34e45c9f4817c3fdd Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 10 Dec 2025 08:46:11 +0100 Subject: [PATCH 09/10] Make UI text elements translatable --- contrib/website_gallery_export.lua | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 843efc92..fe837891 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -28,6 +28,10 @@ local df = require "lib/dtutils.file" local temp = dt.preferences.read('web_gallery', 'title', 'string') if temp == nil then temp = 'Darktable gallery' end +local function _(msgid) + return dt.gettext.gettext(msgid) +end + local title_widget = dt.new_widget("entry") { text = temp @@ -38,8 +42,8 @@ if temp == nil then temp = '' end local dest_dir_widget = dt.new_widget("file_chooser_button") { - title = "select output folder", - tooltip = "select output folder", + title = _("select output folder"), + tooltip = _("select output folder"), value = temp, is_directory = true, changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end @@ -48,9 +52,9 @@ local dest_dir_widget = dt.new_widget("file_chooser_button") local gallery_widget = dt.new_widget("box") { orientation=vertical, - dt.new_widget("label"){label = "gallery title"}, + dt.new_widget("label"){label = _("gallery title")}, title_widget, - dt.new_widget("label"){label = "destination directory"}, + dt.new_widget("label"){label = _("destination directory")}, dest_dir_widget } @@ -117,11 +121,11 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, local images = {} local index = 1 - local job = dt.gui.create_job("exporting thumbnail images", true, stop_job) + local job = dt.gui.create_job(_("exporting thumbnail images"), true, stop_job) for i, image in pairs(images_ordered) do local filename = images_table[image] - dt.print("exporting thumbnail image "..index.."/"..#images_ordered) + dt.print(_("exporting thumbnail image ")..index.."/"..#images_ordered) write_image(image, dest_dir, filename) if exiftool then @@ -164,7 +168,7 @@ local function generate_javascript_gallery_object(gallery) end local function write_javascript_file(gallery_table, dest_dir) - dt.print("write JavaScript file") + dt.print(_("write JavaScript file")) javascript_object = generate_javascript_gallery_object(gallery_table) local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') @@ -177,7 +181,7 @@ local function write_javascript_file(gallery_table, dest_dir) end local function copy_static_files(dest_dir) - dt.print("copy static gallery files") + dt.print(_("copy static gallery files")) gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" gfiles = { "index.html", @@ -200,7 +204,7 @@ local function build_gallery(storage, images_table, extra_data) local images_ordered = extra_data["images"] -- process images in the correct order local sizes = extra_data["sizes"] - local title = "Darktable export" + local title = _("Darktable export") if title_widget.text ~= "" then title = title_widget.text end @@ -231,7 +235,7 @@ script_data.destroy = destroy local function show_status(storage, image, format, filename, number, total, high_quality, extra_data) - dt.print(string.format("export image %i/%i", number, total)) + dt.print(string.format(_("export image").."%i/%i", number, total)) aspect = image.aspect_ratio -- calculate the size of the exported image and store it in extra_data -- to make it available in the finalize function From b0373f7166d3690a4411065db5c368cd05d430ba Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 10 Dec 2025 08:46:24 +0100 Subject: [PATCH 10/10] Use subdirectories in the generated gallery JavaScript files are now in js/, CSS files in css/, thumbnail images in thumbnails/. --- contrib/website_gallery_export.lua | 26 +++++++++++---------- data/website_gallery/{ => css}/gallery.css | 0 data/website_gallery/{ => css}/modal.css | 0 data/website_gallery/index.html | 12 +++++----- data/website_gallery/{ => js}/fullscreen.js | 0 data/website_gallery/{ => js}/gallery.js | 2 +- data/website_gallery/{ => js}/modal.js | 0 7 files changed, 21 insertions(+), 19 deletions(-) rename data/website_gallery/{ => css}/gallery.css (100%) rename data/website_gallery/{ => css}/modal.css (100%) rename data/website_gallery/{ => js}/fullscreen.js (100%) rename data/website_gallery/{ => js}/gallery.js (97%) rename data/website_gallery/{ => js}/modal.js (100%) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index fe837891..f485f0b3 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -86,8 +86,8 @@ local function export_thumbnail(image, filename) end local function write_image(image, dest_dir, filename) - df.file_move(filename, dest_dir.."/"..get_file_name(filename)) - export_thumbnail(image, dest_dir.."/thumb_"..get_file_name(filename)) + df.file_move(filename, dest_dir.."/images/"..get_file_name(filename)) + export_thumbnail(image, dest_dir.."/thumbnails/thumb_"..get_file_name(filename)) end function exiftool_get_image_dimensions(filename) @@ -116,7 +116,6 @@ local function stop_job(job) end local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) - dest_dir = dest_dir.."/images" local gallery_data = { name = escape_js_string(title) } local images = {} @@ -129,7 +128,7 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, write_image(image, dest_dir, filename) if exiftool then - width, height = exiftool_get_image_dimensions(dest_dir.."/"..get_file_name(filename)) + width, height = exiftool_get_image_dimensions(dest_dir.."/images/"..get_file_name(filename)) else width = sizes[index].width height = sizes[index].height @@ -171,7 +170,7 @@ local function write_javascript_file(gallery_table, dest_dir) dt.print(_("write JavaScript file")) javascript_object = generate_javascript_gallery_object(gallery_table) - local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') + local fileOut, errr = io.open(dest_dir.."/js/images.js", 'w+') if fileOut then fileOut:write(javascript_object) else @@ -181,17 +180,17 @@ local function write_javascript_file(gallery_table, dest_dir) end local function copy_static_files(dest_dir) - dt.print(_("copy static gallery files")) gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" - gfiles = { + local gfiles = { "index.html", - "gallery.css", - "modal.css", - "modal.js", - "gallery.js", - "fullscreen.js" + "css/gallery.css", + "css/modal.css", + "js/gallery.js", + "js/modal.js", + "js/fullscreen.js" } + dt.print(_("copy static gallery files")) for _, file in ipairs(gfiles) do df.file_copy(gfsrc.."/"..file, dest_dir.."/"..file) end @@ -201,6 +200,9 @@ local function build_gallery(storage, images_table, extra_data) local dest_dir = dest_dir_widget.value df.mkdir(dest_dir) df.mkdir(dest_dir.."/images") + df.mkdir(dest_dir.."/thumbnails") + df.mkdir(dest_dir.."/css") + df.mkdir(dest_dir.."/js") local images_ordered = extra_data["images"] -- process images in the correct order local sizes = extra_data["sizes"] diff --git a/data/website_gallery/gallery.css b/data/website_gallery/css/gallery.css similarity index 100% rename from data/website_gallery/gallery.css rename to data/website_gallery/css/gallery.css diff --git a/data/website_gallery/modal.css b/data/website_gallery/css/modal.css similarity index 100% rename from data/website_gallery/modal.css rename to data/website_gallery/css/modal.css diff --git a/data/website_gallery/index.html b/data/website_gallery/index.html index e9ea6140..8abf8880 100644 --- a/data/website_gallery/index.html +++ b/data/website_gallery/index.html @@ -4,8 +4,8 @@ - - + + Image Gallery @@ -37,9 +37,9 @@

Image Gallery

- - - - + + + + \ No newline at end of file diff --git a/data/website_gallery/fullscreen.js b/data/website_gallery/js/fullscreen.js similarity index 100% rename from data/website_gallery/fullscreen.js rename to data/website_gallery/js/fullscreen.js diff --git a/data/website_gallery/gallery.js b/data/website_gallery/js/gallery.js similarity index 97% rename from data/website_gallery/gallery.js rename to data/website_gallery/js/gallery.js index 54b0c875..be9a8ed0 100644 --- a/data/website_gallery/gallery.js +++ b/data/website_gallery/js/gallery.js @@ -66,7 +66,7 @@ document.addEventListener('DOMContentLoaded', function () { const img = document.createElement('img'); img.className = 'thumb'; - img.src = imageObj.filename.replace(/images\/(.*)$/i, 'images/thumb_$1'); + img.src = imageObj.filename.replace(/images\/(.*)$/i, 'thumbnails/thumb_$1'); img.alt = imageObj.filename; img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); }); diff --git a/data/website_gallery/modal.js b/data/website_gallery/js/modal.js similarity index 100% rename from data/website_gallery/modal.js rename to data/website_gallery/js/modal.js