improved Modal with zoom and pagination. also implementing thumbnail generation

This commit is contained in:
Bryan Van Deusen
2025-08-01 23:19:17 -04:00
parent e3a4c348f1
commit 63918363c1
15 changed files with 536 additions and 176 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ venv/
.gitignore .gitignore
# Ignore DB files # Ignore DB files
app.db imagerepo/
# Ignore Docker related files # Ignore Docker related files
.dockerignore .dockerignore
+42 -6
View File
@@ -30,11 +30,22 @@ def index():
@main.route('/gallery') @main.route('/gallery')
@login_required @login_required
def gallery(): def gallery():
recent_images = 21 per_page = 35
page = request.args.get('page', 1, type=int)
images = ImageRecord.query.order_by( images = ImageRecord.query.order_by(
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
).limit(recent_images).all() ).paginate(page=page, per_page=per_page)
return render_template('gallery.html', images=images)
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/<path:filename>') @main.route('/images/<path:filename>')
def serve_image(filename): def serve_image(filename):
@@ -43,12 +54,21 @@ def serve_image(filename):
@main.route('/artist/<artist_name>') @main.route('/artist/<artist_name>')
@login_required @login_required
def artist_gallery(artist_name): def artist_gallery(artist_name):
per_page=49 per_page = 35
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
images = ImageRecord.query.filter_by(artist=artist_name).order_by( images = ImageRecord.query.filter_by(artist=artist_name).order_by(
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
).paginate(page=page, per_page=per_page) ).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') @main.route('/artists')
@login_required @login_required
@@ -82,12 +102,28 @@ def settings():
abort(403) abort(403)
return render_template('settings.html') 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') @main.route('/import-images')
@login_required @login_required
def trigger_image_import(): def trigger_image_import():
with open('/import/trigger.flag', 'w') as f: with open('/import/trigger.flag', 'w') as f:
f.write('start') f.write('start')
flash("Image import triggered.") flash("Image import triggered.", "success")
return redirect(url_for('main.settings')) return redirect(url_for('main.settings'))
@main.route('/reset-db', methods=['POST']) @main.route('/reset-db', methods=['POST'])
+1
View File
@@ -14,6 +14,7 @@ class ImageRecord(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False) filename = db.Column(db.String(255), nullable=False)
filepath = db.Column(db.String(512), 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 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 perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash
artist = db.Column(db.String(255), nullable=True) artist = db.Column(db.String(255), nullable=True)
+209
View File
@@ -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();
}
});
});
+82 -38
View File
@@ -43,22 +43,39 @@ body {
transform: translateY(1px); transform: translateY(1px);
} }
.flash-container {
max-width: 800px;
margin: 1rem auto;
padding: 0 1rem;
}
.flash { .flash {
padding: 0.5rem 1rem; padding: 1rem;
margin: 1rem 0; margin-bottom: 1rem;
border-radius: 4px; border-radius: 6px;
font-weight: 500;
color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
.flash.success { .flash-info {
background-color: #d4edda; background-color: #3182ce; /* blue */
color: #155724;
} }
.flash.danger { .flash-message, .flash-success {
background-color: #f8d7da; background-color: #38a169; /* green */
color: #721c24;
} }
.flash-warning {
background-color: #dd6b20; /* orange */
}
.flash-danger,
.flash-error {
background-color: #e53e3e; /* red */
}
.content { .content {
padding: 8px; padding: 8px;
} }
@@ -84,7 +101,7 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 450px; height: 450px;
background-color: rgba(1,1,1,.1); background-color: #1c1c1c;
box-shadow: 0 3px 4px rgba(0,0,0,0.7); box-shadow: 0 3px 4px rgba(0,0,0,0.7);
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
@@ -140,6 +157,11 @@ body {
border-color: #007BFF; border-color: #007BFF;
} }
.pagination-ellipsis {
padding: 0 0.5rem;
color: #999;
}
/* MODAL STYLES */ /* MODAL STYLES */
.modal { .modal {
display: none; display: none;
@@ -157,72 +179,94 @@ body {
.modal-content { .modal-content {
position: relative; position: relative;
max-width: 95vw; width: 100%;
max-width: 1600px;
max-height: 95vh; max-height: 95vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
/* Modal body */
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%;
} }
.modal-body img { /* Image wrapper for tall image logic */
max-width: 90vw; .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; max-height: 80vh;
object-fit: contain; object-fit: contain;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 0 10px rgba(0,0,0,0.6); box-shadow: 0 0 10px rgba(0,0,0,0.6);
} }
.modal-filename { /* Adjust for very tall images */
color: #fff; .modal-image-wrapper.too-tall {
background: rgba(0, 0, 0, 0.6); overflow-y: auto;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
text-align: center;
word-break: break-word;
} }
/* Modal arrows and close */ .modal-image-wrapper.too-tall img {
width: 90vw;
height: auto;
max-height: none;
}
/* Modal buttons */
.modal-button { .modal-button {
position: absolute; position: absolute;
top: 50%; top: 10%;
transform: translateY(-50%); bottom: 10%;
background: rgba(0, 0, 0, 0.4); width: 60px;
background: rgba(0, 0, 0, 0.3);
border: none; border: none;
color: white; color: white;
font-size: 2rem; font-size: 2.5rem;
padding: 0.5rem 1rem;
cursor: pointer; cursor: pointer;
z-index: 1001; z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
border-radius: 4px;
} }
.modal:hover .modal-button { .modal-content:hover .modal-button {
opacity: 1; opacity: 1;
} }
.modal-prev { .modal-prev {
left: -60px; left: 0;
} }
.modal-next { .modal-next {
right: -60px; right: 0;
}
.modal-close {
top: 10px;
right: 10px;
transform: none;
font-size: 1.5rem;
opacity: 1;
} }
/* Artist Styling */ /* Artist Styling */
+59
View File
@@ -0,0 +1,59 @@
{% if images.pages > 1 %}
<div class="pagination-container">
{% 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 %}
<a class="pagination-link" href="{{ url_for(endpoint, page=1, **request.view_args) }}">« First</a>
{% endif %}
<a class="pagination-link" href="{{ url_for(endpoint, page=images.prev_num, **request.view_args) }}">← Previous</a>
{% endif %}
{# Left ellipsis #}
{% if start_page > 1 %}
<a class="pagination-link" href="{{ url_for(endpoint, page=1, **request.view_args) }}">1</a>
{% if start_page > 2 %}
<span class="pagination-ellipsis"></span>
{% endif %}
{% endif %}
{# Page numbers #}
{% for p in range(start_page, end_page + 1) %}
<a class="pagination-link {% if p == current_page %}active{% endif %}" href="{{ url_for(endpoint, page=p, **request.view_args) }}">{{ p }}</a>
{% endfor %}
{# Right ellipsis #}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="pagination-ellipsis"></span>
{% endif %}
<a class="pagination-link" href="{{ url_for(endpoint, page=total_pages, **request.view_args) }}">{{ total_pages }}</a>
{% endif %}
{# Next and Last #}
{% if images.has_next %}
<a class="pagination-link" href="{{ url_for(endpoint, page=images.next_num, **request.view_args) }}">Next →</a>
{% if current_page < total_pages %}
<a class="pagination-link" href="{{ url_for(endpoint, page=total_pages, **request.view_args) }}">Last »</a>
{% endif %}
{% endif %}
</div>
{% endif %}
+6 -31
View File
@@ -1,54 +1,29 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ artist }}'s Gallery{% endblock %} {% block title %}{{ artist }}'s Gallery{% endblock %}
{% block content %} {% block content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1> <h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<div class="pagination-container"> {% include '_pagination.html' %}
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% for p in range(1, images.pages + 1) %}
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
{% endfor %}
{% if images.has_next %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
{% endif %}
</div>
<!-- Gallery Grid -->
<div class="gallery-grid"> <div class="gallery-grid">
{% for image in images.items %} {% for image in images %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');" style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"
data-filename="{{ image.filename }}"> data-filename="{{ image.filename }}">
</div> </div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date"> <span class="image-date">
{{ (image.taken_at or image.imported_at) | datetimeformat }} {{ image.taken_at or image.imported_at | datetimeformat }}
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<div class="pagination-container"> {% include '_pagination.html' %}
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% for p in range(1, images.pages + 1) %}
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
{% endfor %}
{% if images.has_next %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
{% endif %}
</div>
{% endblock %} {% endblock %}
+2 -3
View File
@@ -2,8 +2,7 @@
{% block title %}Artists{% endblock %} {% block title %}Artists{% endblock %}
{% block content %} {% block content %}
<h1 style="text-align:center; margin-top: 1rem;">Artists</h1> <h1>Artists</h1>
<div class="artist-grid"> <div class="artist-grid">
{% for artist in artist_data %} {% for artist in artist_data %}
<a href="{{ url_for('main.artist_gallery', artist_name=artist.name) }}" class="artist-card-link"> <a href="{{ url_for('main.artist_gallery', artist_name=artist.name) }}" class="artist-card-link">
@@ -12,7 +11,7 @@
<div class="artist-preview"> <div class="artist-preview">
{% for image in artist.images %} {% for image in artist.images %}
<div class="artist-thumb" <div class="artist-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');"> style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');">
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+14 -6
View File
@@ -1,22 +1,30 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Gallery{% endblock %} {% block title %}Gallery{% endblock %}
{% block content %} {% block content %}
<!-- Gallery Thumbnail Grid --> <h1 style="text-align:center; margin-top: 1rem;">Gallery</h1>
<!-- Pagination Controls (Top) -->
{% include '_pagination.html' %}
<!-- Gallery Grid -->
<div class="gallery-grid"> <div class="gallery-grid">
{% for image in images %} {% for image in images.items %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');" style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}');"
data-filename="{{ image.filename }}"> data-filename="{{ image.filename }}">
</div> </div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date"> <span class="image-date">
{{ (image.taken_at or image.imported_at) | datetimeformat }} {{ image.taken_at or image.imported_at | datetimeformat }}
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination Controls (Bottom) -->
{% include '_pagination.html' %}
{% endblock %} {% endblock %}
+19 -75
View File
@@ -31,15 +31,15 @@
<!-- FLASH MESSAGES --> <!-- FLASH MESSAGES -->
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="flash-container">
{% for category, message in messages %} {% for category, message in messages %}
<div class="flash {{ category }}"> <div class="flash flash-{{ category }}">{{ message }}</div>
{{ message }}
</div>
{% endfor %} {% endfor %}
</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<hr> <hr>
@@ -49,86 +49,30 @@
</div> </div>
</div> </div>
{% if request.endpoint == 'main.gallery' or request.endpoint == 'main.artist_gallery' %}
<input type="hidden" id="hasNextPage" value="{{ has_next|lower }}">
<input type="hidden" id="nextPageUrl" value="{{ next_page_url }}">
<input type="hidden" id="hasPrevPage" value="{{ has_prev|lower }}">
<input type="hidden" id="prevPageUrl" value="{{ prev_page_url }}">
{% endif %}
<!-- Modal --> <!-- Modal -->
<div id="imageModal" class="modal"> <div id="imageModal" class="modal">
<div class="modal-dialog modal-xl modal-dialog-centered"> <div class="modal-content">
<div class="modal-content bg-transparent border-0"> <!-- Navigation buttons -->
<button id="modalClose" class="modal-button modal-close"></button>
<button id="modalPrev" class="modal-button modal-prev"></button> <button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button> <button id="modalNext" class="modal-button modal-next"></button>
<div class="modal-body"> <div class="modal-body">
<h5 id="modalFilename" class="modal-filename"></h5> <div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" class="img-fluid"> <img id="modalImage" src="" alt="Full View">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal');
const modalImage = document.getElementById('modalImage');
const modalClose = document.getElementById('modalClose');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalFilename = document.getElementById('modalFilename');
const images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1;
function openModal(index) {
currentIndex = index;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
modal.classList.add('active');
}
function closeModal() {
modal.classList.remove('active');
modalImage.src = '';
modalFilename.textContent = '';
currentIndex = -1;
}
function showNext() {
if (images.length === 0) return;
currentIndex = (currentIndex + 1) % images.length;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
}
function showPrev() {
if (images.length === 0) return;
currentIndex = (currentIndex - 1 + images.length) % images.length;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
}
images.forEach((img, index) => {
img.addEventListener('click', () => openModal(index));
});
modalClose.addEventListener('click', closeModal);
modalNext.addEventListener('click', showNext);
modalPrev.addEventListener('click', showPrev);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowRight') showNext();
else if (e.key === 'ArrowLeft') showPrev();
else if (e.key === 'Escape') closeModal();
});
});
</script>
</body> </body>
</html> </html>
+6
View File
@@ -12,9 +12,15 @@
<button type="submit" class="btn primary-btn">Trigger Image Import</button> <button type="submit" class="btn primary-btn">Trigger Image Import</button>
</form> </form>
<form action="{{ url_for('main.trigger_thumbnail_generation') }}" method="post" onsubmit="return confirm('Are you sure you want to regenerate all thumbnails?');">
<button type="submit" class="btn warning-btn">Regenerate Thumbnails</button>
</form>
<form action="{{ url_for('main.reset_db') }}" method="post" onsubmit="return confirm('Are you sure you want to delete all image records? This action cannot be undone.');"> <form action="{{ url_for('main.reset_db') }}" method="post" onsubmit="return confirm('Are you sure you want to delete all image records? This action cannot be undone.');">
<button type="submit" class="btn danger-btn">Reset Image Database</button> <button type="submit" class="btn danger-btn">Reset Image Database</button>
</form> </form>
</div> </div>
</div> </div>
+30 -4
View File
@@ -6,12 +6,17 @@ from PIL import Image
import exifread import exifread
import mimetypes import mimetypes
import imagehash import imagehash
import uuid
from app import db from app import db
from app.models import ImageRecord from app.models import ImageRecord
THUMB_DIR = "/images/thumbs"
THUMB_SIZE = (400, 400)
def import_images_task(source_dir, dest_dir): def import_images_task(source_dir, dest_dir):
imported = [] imported = []
os.makedirs(THUMB_DIR, exist_ok=True)
for artist_dir in os.listdir(source_dir): for artist_dir in os.listdir(source_dir):
artist_path = os.path.join(source_dir, artist_dir) artist_path = os.path.join(source_dir, artist_dir)
@@ -43,9 +48,19 @@ def import_images_task(source_dir, dest_dir):
dest_path = os.path.join(target_dir, filename) dest_path = os.path.join(target_dir, filename)
shutil.copy2(full_path, dest_path) shutil.copy2(full_path, dest_path)
# Generate thumbnail
thumb_filename = f"{uuid.uuid4().hex}.jpg"
thumb_path = os.path.join(THUMB_DIR, thumb_filename)
try:
generate_thumbnail(full_path, thumb_path)
except Exception as e:
print(f"[WARN] Failed to generate thumbnail: {e}")
thumb_path = None
record = ImageRecord( record = ImageRecord(
filename=filename, filename=filename,
filepath=dest_path, filepath=dest_path,
thumb_path=thumb_path,
hash=file_hash, hash=file_hash,
perceptual_hash=str(phash) if phash else None, perceptual_hash=str(phash) if phash else None,
artist=artist_dir, artist=artist_dir,
@@ -61,11 +76,25 @@ def import_images_task(source_dir, dest_dir):
imported.append(filename) imported.append(filename)
# Add delay between each image # Add delay between each image
db.session.commit()
time.sleep(1) time.sleep(1)
db.session.commit()
return f"Imported {len(imported)} images." return f"Imported {len(imported)} images."
def generate_thumbnail(image_path, size=(400, 400), overwrite=False):
thumbnail_dir = THUMB_DIR # update this accordingly
basename = os.path.basename(image_path)
thumb_path = os.path.join(thumbnail_dir, basename)
if not overwrite and os.path.exists(thumb_path):
return thumb_path
with Image.open(image_path) as img:
img.thumbnail(size)
img.save(thumb_path)
return thumb_path
def calculate_hash(file_path): def calculate_hash(file_path):
hash_func = hashlib.sha256() hash_func = hashlib.sha256()
@@ -74,7 +103,6 @@ def calculate_hash(file_path):
hash_func.update(chunk) hash_func.update(chunk)
return hash_func.hexdigest() return hash_func.hexdigest()
def calculate_perceptual_hash(file_path): def calculate_perceptual_hash(file_path):
try: try:
with Image.open(file_path) as img: with Image.open(file_path) as img:
@@ -83,7 +111,6 @@ def calculate_perceptual_hash(file_path):
print(f"[WARN] Failed to compute perceptual hash for {file_path}: {e}") print(f"[WARN] Failed to compute perceptual hash for {file_path}: {e}")
return None return None
def is_similar_image(phash, width, height, threshold=2): def is_similar_image(phash, width, height, threshold=2):
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all(): for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all():
try: try:
@@ -96,7 +123,6 @@ def is_similar_image(phash, width, height, threshold=2):
print(f"[WARN] Failed pHash comparison: {e}") print(f"[WARN] Failed pHash comparison: {e}")
return False return False
def extract_metadata(file_path): def extract_metadata(file_path):
metadata = { metadata = {
'file_size': None, 'file_size': None,
+1 -2
View File
@@ -7,5 +7,4 @@ services:
- ./imagerepo/db/:/db # SQLite db storage - ./imagerepo/db/:/db # SQLite db storage
- ./imagerepo/images:/images # where the app will store it's imported images - ./imagerepo/images:/images # where the app will store it's imported images
- ./import:/import # where it will import images from - ./import:/import # where it will import images from
- ./migrations:/app/migrations # for dev
+25 -3
View File
@@ -1,9 +1,10 @@
# image_import_loop.py # image_import_worker.py
import time import time
import os import os
from app import create_app from app import create_app
from app.utils.image_importer import import_images_task from app.utils.image_importer import import_images_task
THUMBNAIL_FLAG = "/import/thumbnail.flag"
TRIGGER_FLAG = "/import/trigger.flag" TRIGGER_FLAG = "/import/trigger.flag"
SOURCE_DIR = "/import" SOURCE_DIR = "/import"
DEST_DIR = "/images" DEST_DIR = "/images"
@@ -29,8 +30,29 @@ def monitor_and_import():
print("[Image Importer] Trigger flag cleared.") print("[Image Importer] Trigger flag cleared.")
except Exception as e: except Exception as e:
print(f"[Image Importer] Failed to remove trigger flag: {e}") print(f"[Image Importer] Failed to remove trigger flag: {e}")
else:
print("[Image Importer] No trigger flag. Sleeping...") # Thumbnail generation Check
if os.path.exists(THUMBNAIL_FLAG):
print("[Image Importer] Thumbnail regeneration triggered.")
try:
from app.models import ImageRecord
from app.utils.image_importer import generate_thumbnail
for img in ImageRecord.query.all():
try:
generate_thumbnail(img.filepath, overwrite=True)
print(f"[Thumbnail] Regenerated for {img.filename}")
except Exception as e:
print(f"[Thumbnail] Failed for {img.filename}: {e}")
except Exception as e:
print(f"[Image Importer] Error during thumbnail regeneration: {e}")
finally:
try:
os.remove(THUMBNAIL_FLAG)
print("[Image Importer] Thumbnail flag cleared.")
except Exception as e:
print(f"[Image Importer] Failed to remove thumbnail flag: {e}")
time.sleep(CHECK_INTERVAL) time.sleep(CHECK_INTERVAL)
@@ -0,0 +1,32 @@
"""thumbnail processing
Revision ID: 861f9cae4fe5
Revises: 9c3559ba0427
Create Date: 2025-08-01 22:32:30.089380
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '861f9cae4fe5'
down_revision = '9c3559ba0427'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('image_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('thumb_path', sa.String(length=512), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('image_record', schema=None) as batch_op:
batch_op.drop_column('thumb_path')
# ### end Alembic commands ###