From 63918363c1b8eb947b94796f088eff2ac54d4bc3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 Aug 2025 23:19:17 -0400 Subject: [PATCH] improved Modal with zoom and pagination. also implementing thumbnail generation --- .dockerignore | 2 +- app/main.py | 48 +++- app/models.py | 1 + app/static/js/modal-pagination.js | 209 ++++++++++++++++++ app/static/style.css | 120 ++++++---- app/templates/_pagination.html | 59 +++++ app/templates/artist_gallery.html | 39 +--- app/templates/artist_list.html | 5 +- app/templates/gallery.html | 20 +- app/templates/layout.html | 104 ++------- app/templates/settings.html | 6 + app/utils/image_importer.py | 36 ++- docker-compose.yml | 3 +- image_import_worker.py | 28 ++- .../861f9cae4fe5_thumbnail_processing.py | 32 +++ 15 files changed, 536 insertions(+), 176 deletions(-) create mode 100644 app/static/js/modal-pagination.js create mode 100644 app/templates/_pagination.html create mode 100644 migrations/versions/861f9cae4fe5_thumbnail_processing.py diff --git a/.dockerignore b/.dockerignore index 79bc907..287cf79 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,7 +21,7 @@ venv/ .gitignore # Ignore DB files -app.db +imagerepo/ # Ignore Docker related files .dockerignore diff --git a/app/main.py b/app/main.py index e9af1cb..a649dbd 100644 --- a/app/main.py +++ b/app/main.py @@ -30,11 +30,22 @@ def index(): @main.route('/gallery') @login_required def gallery(): - recent_images = 21 + per_page = 35 + page = request.args.get('page', 1, type=int) + images = ImageRecord.query.order_by( desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) - ).limit(recent_images).all() - return render_template('gallery.html', images=images) + ).paginate(page=page, per_page=per_page) + + return render_template( + 'gallery.html', + images=images, + has_next=images.has_next, + next_page_url=url_for('main.gallery', page=images.next_num) if images.has_next else '', + has_prev=images.has_prev, + prev_page_url=url_for('main.gallery', page=images.prev_num) if images.has_prev else '' + ) + @main.route('/images/') def serve_image(filename): @@ -43,12 +54,21 @@ def serve_image(filename): @main.route('/artist/') @login_required def artist_gallery(artist_name): - per_page=49 + per_page = 35 page = request.args.get('page', 1, type=int) images = ImageRecord.query.filter_by(artist=artist_name).order_by( desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) ).paginate(page=page, per_page=per_page) - return render_template('artist_gallery.html', images=images, artist=artist_name) + + return render_template( + 'artist_gallery.html', + images=images, + artist=artist_name, + has_next=images.has_next, + next_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.next_num) if images.has_next else '', + has_prev=images.has_prev, + prev_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.prev_num) if images.has_prev else '' + ) @main.route('/artists') @login_required @@ -82,12 +102,28 @@ def settings(): abort(403) return render_template('settings.html') +@main.route('/trigger-thumbnail-generation', methods=['POST']) +@login_required +def trigger_thumbnail_generation(): + if not current_user.is_admin: + flash("You do not have permission to perform this action.", "danger") + return redirect(url_for('main.settings')) + + try: + with open('/import/thumbnail.flag', 'w') as f: + f.write("trigger") + flash("Thumbnail regeneration has been triggered.", "success") + except Exception as e: + flash(f"Failed to trigger thumbnail regeneration: {e}", "danger") + + return redirect(url_for('main.settings')) + @main.route('/import-images') @login_required def trigger_image_import(): with open('/import/trigger.flag', 'w') as f: f.write('start') - flash("Image import triggered.") + flash("Image import triggered.", "success") return redirect(url_for('main.settings')) @main.route('/reset-db', methods=['POST']) diff --git a/app/models.py b/app/models.py index 1951e71..f43b32d 100644 --- a/app/models.py +++ b/app/models.py @@ -14,6 +14,7 @@ class ImageRecord(db.Model): id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(512), nullable=False) + thumb_path = db.Column(db.String(512)) hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash artist = db.Column(db.String(255), nullable=True) diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js new file mode 100644 index 0000000..9ef1561 --- /dev/null +++ b/app/static/js/modal-pagination.js @@ -0,0 +1,209 @@ +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('imageModal'); + const modalImage = document.getElementById('modalImage'); + const modalPrev = document.getElementById('modalPrev'); + const modalNext = document.getElementById('modalNext'); + const modalClose = document.getElementById('modalClose'); + const modalImageWrapper = document.querySelector('.modal-image-wrapper'); + + modalImage.setAttribute('draggable', 'false'); + + let images = Array.from(document.querySelectorAll('.img-clickable')); + let currentIndex = -1; + let isZoomed = false; + let isDragging = false; + let startX, startY, scrollLeft, scrollTop; + let dragStartX = 0; + let dragStartY = 0; + let dragMoved = false; + + const hasNextPageInput = document.getElementById('hasNextPage'); + const nextPageUrlInput = document.getElementById('nextPageUrl'); + const hasPrevPageInput = document.getElementById('hasPrevPage'); + const prevPageUrlInput = document.getElementById('prevPageUrl'); + + function updateImage(index) { + const img = images[index]; + modalImage.src = img.dataset.full; + currentIndex = index; + resetZoom(); + } + + function openModal(index) { + modal.classList.add('active'); + updateImage(index); + history.replaceState({ modalIndex: index }, '', window.location.href); + } + + function closeModal() { + modal.classList.remove('active'); + modalImage.src = ''; + currentIndex = -1; + resetZoom(); + history.replaceState({}, '', window.location.pathname + window.location.search); + } + + function showNext() { + if (currentIndex < images.length - 1) { + updateImage(currentIndex + 1); + } else if (hasNextPageInput?.value === "true") { + fetch(nextPageUrlInput.value) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newGallery = doc.querySelector('.gallery-grid'); + const newImages = doc.querySelectorAll('.img-clickable'); + + if (newGallery && newImages.length > 0) { + const gallery = document.querySelector('.gallery-grid'); + gallery.innerHTML = newGallery.innerHTML; + + images = Array.from(document.querySelectorAll('.img-clickable')); + images.forEach((img, i) => { + img.addEventListener('click', () => openModal(i)); + }); + + const newHasNext = doc.getElementById('hasNextPage')?.value || "false"; + const newNextUrl = doc.getElementById('nextPageUrl')?.value || ""; + const newHasPrev = doc.getElementById('hasPrevPage')?.value || "false"; + const newPrevUrl = doc.getElementById('prevPageUrl')?.value || ""; + + hasNextPageInput.value = newHasNext; + nextPageUrlInput.value = newNextUrl; + hasPrevPageInput.value = newHasPrev; + prevPageUrlInput.value = newPrevUrl; + + history.pushState({}, '', nextPageUrlInput.value); + openModal(0); + } + }); + } + } + + function showPrev() { + if (currentIndex > 0) { + updateImage(currentIndex - 1); + } else if (hasPrevPageInput?.value === "true") { + fetch(prevPageUrlInput.value) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newGallery = doc.querySelector('.gallery-grid'); + const newImages = doc.querySelectorAll('.img-clickable'); + + if (newGallery && newImages.length > 0) { + const gallery = document.querySelector('.gallery-grid'); + gallery.innerHTML = newGallery.innerHTML; + + images = Array.from(document.querySelectorAll('.img-clickable')); + images.forEach((img, i) => { + img.addEventListener('click', () => openModal(i)); + }); + + const newHasNext = doc.getElementById('hasNextPage')?.value || "false"; + const newNextUrl = doc.getElementById('nextPageUrl')?.value || ""; + const newHasPrev = doc.getElementById('hasPrevPage')?.value || "false"; + const newPrevUrl = doc.getElementById('prevPageUrl')?.value || ""; + + hasNextPageInput.value = newHasNext; + nextPageUrlInput.value = newNextUrl; + hasPrevPageInput.value = newHasPrev; + prevPageUrlInput.value = newPrevUrl; + + history.pushState({}, '', prevPageUrlInput.value); + openModal(images.length - 1); + } + }); + } + } + + function toggleZoom() { + isZoomed = !isZoomed; + modalImageWrapper.classList.toggle('zoomed', isZoomed); + modalImageWrapper.style.cursor = isZoomed ? 'grab' : ''; + if (!isZoomed) { + modalImageWrapper.scrollLeft = 0; + modalImageWrapper.scrollTop = 0; + } + } + + function resetZoom() { + isZoomed = false; + modalImageWrapper.classList.remove('zoomed'); + modalImageWrapper.scrollLeft = 0; + modalImageWrapper.scrollTop = 0; + modalImageWrapper.style.cursor = ''; + } + + modalImageWrapper.addEventListener('mousedown', (e) => { + if (!isZoomed) return; + isDragging = true; + dragMoved = false; + modalImageWrapper.style.cursor = 'grabbing'; + startX = e.pageX - modalImageWrapper.offsetLeft; + startY = e.pageY - modalImageWrapper.offsetTop; + scrollLeft = modalImageWrapper.scrollLeft; + scrollTop = modalImageWrapper.scrollTop; + dragStartX = e.pageX; + dragStartY = e.pageY; + }); + + modalImageWrapper.addEventListener('mouseleave', () => { + isDragging = false; + if (isZoomed) modalImageWrapper.style.cursor = 'grab'; + }); + + modalImageWrapper.addEventListener('mouseup', () => { + isDragging = false; + if (isZoomed) modalImageWrapper.style.cursor = 'grab'; + }); + + modalImageWrapper.addEventListener('mousemove', (e) => { + if (!isDragging || !isZoomed) return; + e.preventDefault(); + dragMoved = true; + const x = e.pageX - modalImageWrapper.offsetLeft; + const y = e.pageY - modalImageWrapper.offsetTop; + const walkX = x - startX; + const walkY = y - startY; + modalImageWrapper.scrollLeft = scrollLeft - walkX; + modalImageWrapper.scrollTop = scrollTop - walkY; + }); + + modalImageWrapper.addEventListener('click', (e) => { + if (dragMoved) { + dragMoved = false; + return; + } + toggleZoom(); + }); + + modalClose?.addEventListener('click', closeModal); + modalPrev?.addEventListener('click', showPrev); + modalNext?.addEventListener('click', showNext); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + document.addEventListener('keydown', (e) => { + if (!modal.classList.contains('active')) return; + if (e.key === 'ArrowRight') showNext(); + if (e.key === 'ArrowLeft') showPrev(); + if (e.key === 'Escape') closeModal(); + }); + + images.forEach((img, index) => { + img.addEventListener('click', () => openModal(index)); + }); + + window.addEventListener('popstate', () => { + if (modal.classList.contains('active')) { + closeModal(); + } else { + location.reload(); + } + }); +}); diff --git a/app/static/style.css b/app/static/style.css index 64792a6..b692168 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -43,22 +43,39 @@ body { transform: translateY(1px); } +.flash-container { + max-width: 800px; + margin: 1rem auto; + padding: 0 1rem; +} + .flash { - padding: 0.5rem 1rem; - margin: 1rem 0; - border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 6px; + font-weight: 500; + color: #fff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); } -.flash.success { - background-color: #d4edda; - color: #155724; +.flash-info { + background-color: #3182ce; /* blue */ } -.flash.danger { - background-color: #f8d7da; - color: #721c24; +.flash-message, .flash-success { + background-color: #38a169; /* green */ } +.flash-warning { + background-color: #dd6b20; /* orange */ +} + +.flash-danger, +.flash-error { + background-color: #e53e3e; /* red */ +} + + .content { padding: 8px; } @@ -84,7 +101,7 @@ body { flex-direction: column; align-items: center; height: 450px; - background-color: rgba(1,1,1,.1); + background-color: #1c1c1c; box-shadow: 0 3px 4px rgba(0,0,0,0.7); padding: 8px; border-radius: 8px; @@ -140,6 +157,11 @@ body { border-color: #007BFF; } +.pagination-ellipsis { + padding: 0 0.5rem; + color: #999; +} + /* MODAL STYLES */ .modal { display: none; @@ -157,72 +179,94 @@ body { .modal-content { position: relative; - max-width: 95vw; + width: 100%; + max-width: 1600px; max-height: 95vh; display: flex; flex-direction: column; align-items: center; } + +/* Modal body */ .modal-body { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; + width: 100%; } -.modal-body img { - max-width: 90vw; +/* Image wrapper for tall image logic */ +.modal-image-wrapper { + max-width: 100%; + max-height: 80vh; + overflow: hidden; + cursor: default; +} + +.modal-image-wrapper.zoomed { + overflow: auto; + cursor: grab; +} + +.modal-image-wrapper.zoomed img { + width: auto; + height: auto; + max-width: none; + max-height: none; +} + + +.modal-image-wrapper img { + max-width: 100%; max-height: 80vh; object-fit: contain; border-radius: 6px; box-shadow: 0 0 10px rgba(0,0,0,0.6); } -.modal-filename { - color: #fff; - background: rgba(0, 0, 0, 0.6); - padding: 0.5rem 1rem; - border-radius: 4px; - font-size: 0.9rem; - text-align: center; - word-break: break-word; +/* Adjust for very tall images */ +.modal-image-wrapper.too-tall { + overflow-y: auto; } -/* Modal arrows and close */ +.modal-image-wrapper.too-tall img { + width: 90vw; + height: auto; + max-height: none; +} + +/* Modal buttons */ .modal-button { position: absolute; - top: 50%; - transform: translateY(-50%); - background: rgba(0, 0, 0, 0.4); + top: 10%; + bottom: 10%; + width: 60px; + background: rgba(0, 0, 0, 0.3); border: none; color: white; - font-size: 2rem; - padding: 0.5rem 1rem; + font-size: 2.5rem; cursor: pointer; z-index: 1001; + display: flex; + align-items: center; + justify-content: center; opacity: 0; transition: opacity 0.2s; + border-radius: 4px; } -.modal:hover .modal-button { +.modal-content:hover .modal-button { opacity: 1; } .modal-prev { - left: -60px; + left: 0; } .modal-next { - right: -60px; -} - -.modal-close { - top: 10px; - right: 10px; - transform: none; - font-size: 1.5rem; - opacity: 1; + right: 0; } /* Artist Styling */ diff --git a/app/templates/_pagination.html b/app/templates/_pagination.html new file mode 100644 index 0000000..665cb5b --- /dev/null +++ b/app/templates/_pagination.html @@ -0,0 +1,59 @@ +{% if images.pages > 1 %} +
+ {% set total_pages = images.pages %} + {% set current_page = images.page %} + {% set window_size = 7 %} + {% set endpoint = request.endpoint %} + + {% if total_pages <= window_size %} + {% set start_page = 1 %} + {% set end_page = total_pages %} + {% elif current_page <= 4 %} + {% set start_page = 1 %} + {% set end_page = window_size %} + {% elif current_page > total_pages - 4 %} + {% set start_page = total_pages - window_size + 1 %} + {% set end_page = total_pages %} + {% else %} + {% set start_page = current_page - 3 %} + {% set end_page = current_page + 3 %} + {% endif %} + + {# First and Prev #} + {% if images.has_prev %} + {% if current_page > 1 %} + « First + {% endif %} + ← Previous + {% endif %} + + {# Left ellipsis #} + {% if start_page > 1 %} + 1 + {% if start_page > 2 %} + + {% endif %} + {% endif %} + + {# Page numbers #} + {% for p in range(start_page, end_page + 1) %} + {{ p }} + {% endfor %} + + {# Right ellipsis #} + {% if end_page < total_pages %} + {% if end_page < total_pages - 1 %} + + {% endif %} + {{ total_pages }} + {% endif %} + + {# Next and Last #} + {% if images.has_next %} + Next → + {% if current_page < total_pages %} + Last » + {% endif %} + {% endif %} +
+{% endif %} diff --git a/app/templates/artist_gallery.html b/app/templates/artist_gallery.html index e06d680..d73263b 100644 --- a/app/templates/artist_gallery.html +++ b/app/templates/artist_gallery.html @@ -1,54 +1,29 @@ {% extends "layout.html" %} - {% block title %}{{ artist }}'s Gallery{% endblock %} {% block content %}

{{ artist }}

-
- {% if images.has_prev %} - ← Previous - {% endif %} +{% include '_pagination.html' %} - {% for p in range(1, images.pages + 1) %} - {{ p }} - {% endfor %} - - {% if images.has_next %} - Next → - {% endif %} -
- - -
- {% if images.has_prev %} - ← Previous - {% endif %} +{% include '_pagination.html' %} - {% for p in range(1, images.pages + 1) %} - {{ p }} - {% endfor %} - - {% if images.has_next %} - Next → - {% endif %} -
{% endblock %} diff --git a/app/templates/artist_list.html b/app/templates/artist_list.html index 31844ef..208efea 100644 --- a/app/templates/artist_list.html +++ b/app/templates/artist_list.html @@ -2,8 +2,7 @@ {% block title %}Artists{% endblock %} {% block content %} -

Artists

- +

Artists

{% for artist in artist_data %} @@ -12,7 +11,7 @@
{% for image in artist.images %}
+ style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');">
{% endfor %}
diff --git a/app/templates/gallery.html b/app/templates/gallery.html index 7c4a76b..4e0d093 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -1,22 +1,30 @@ {% extends "layout.html" %} - {% block title %}Gallery{% endblock %} {% block content %} - +

Gallery

+ + +{% include '_pagination.html' %} + +
+ + +{% include '_pagination.html' %} + {% endblock %} diff --git a/app/templates/layout.html b/app/templates/layout.html index 2584129..6a48bef 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -31,15 +31,15 @@ - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
{% for category, message in messages %} -
- {{ message }} -
+
{{ message }}
{% endfor %} - {% endif %} - {% endwith %} +
+ {% endif %} +{% endwith %}
@@ -49,86 +49,30 @@
+{% if request.endpoint == 'main.gallery' or request.endpoint == 'main.artist_gallery' %} + + + + +{% endif %} + +