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
# Ignore DB files
app.db
imagerepo/
# Ignore Docker related files
.dockerignore
+42 -6
View File
@@ -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/<path:filename>')
def serve_image(filename):
@@ -43,12 +54,21 @@ def serve_image(filename):
@main.route('/artist/<artist_name>')
@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'])
+1
View File
@@ -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)
+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);
}
.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 */
+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 %}
+7 -32
View File
@@ -1,54 +1,29 @@
{% extends "layout.html" %}
{% block title %}{{ artist }}'s Gallery{% endblock %}
{% block content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1>
<!-- Pagination Controls -->
<div class="pagination-container">
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% include '_pagination.html' %}
{% 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">
{% for image in images.items %}
{% for image in images %}
<div class="gallery-item">
<div class="gallery-thumb img-clickable"
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/', '')) }}');"
data-filename="{{ image.filename }}">
data-full="{{ 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 }}">
</div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">
{{ (image.taken_at or image.imported_at) | datetimeformat }}
{{ image.taken_at or image.imported_at | datetimeformat }}
</span>
</div>
{% endfor %}
</div>
<!-- Pagination Controls -->
<div class="pagination-container">
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% include '_pagination.html' %}
{% 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 %}
+2 -3
View File
@@ -2,8 +2,7 @@
{% block title %}Artists{% endblock %}
{% block content %}
<h1 style="text-align:center; margin-top: 1rem;">Artists</h1>
<h1>Artists</h1>
<div class="artist-grid">
{% for artist in artist_data %}
<a href="{{ url_for('main.artist_gallery', artist_name=artist.name) }}" class="artist-card-link">
@@ -12,7 +11,7 @@
<div class="artist-preview">
{% for image in artist.images %}
<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>
{% endfor %}
</div>
+14 -6
View File
@@ -1,22 +1,30 @@
{% extends "layout.html" %}
{% block title %}Gallery{% endblock %}
{% 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">
{% for image in images %}
{% for image in images.items %}
<div class="gallery-item">
<div class="gallery-thumb img-clickable"
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/', '')) }}');"
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/', '') ) }}');"
data-filename="{{ image.filename }}">
</div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">
{{ (image.taken_at or image.imported_at) | datetimeformat }}
{{ image.taken_at or image.imported_at | datetimeformat }}
</span>
</div>
{% endfor %}
</div>
<!-- Pagination Controls (Bottom) -->
{% include '_pagination.html' %}
{% endblock %}
+24 -80
View File
@@ -31,15 +31,15 @@
<!-- FLASH MESSAGES -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="flash {{ category }}">
{{ message }}
</div>
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% endif %}
{% endwith %}
<hr>
@@ -49,86 +49,30 @@
</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 -->
<div id="imageModal" class="modal">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<button id="modalClose" class="modal-button modal-close"></button>
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<div class="modal-body">
<h5 id="modalFilename" class="modal-filename"></h5>
<img id="modalImage" src="" class="img-fluid">
<div class="modal-content">
<!-- Navigation buttons -->
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<div class="modal-body">
<div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View">
</div>
</div>
</div>
</div>
<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>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
</body>
</html>
+6
View File
@@ -12,9 +12,15 @@
<button type="submit" class="btn primary-btn">Trigger Image Import</button>
</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.');">
<button type="submit" class="btn danger-btn">Reset Image Database</button>
</form>
</div>
</div>
+31 -5
View File
@@ -6,12 +6,17 @@ from PIL import Image
import exifread
import mimetypes
import imagehash
import uuid
from app import db
from app.models import ImageRecord
THUMB_DIR = "/images/thumbs"
THUMB_SIZE = (400, 400)
def import_images_task(source_dir, dest_dir):
imported = []
os.makedirs(THUMB_DIR, exist_ok=True)
for artist_dir in os.listdir(source_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)
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(
filename=filename,
filepath=dest_path,
thumb_path=thumb_path,
hash=file_hash,
perceptual_hash=str(phash) if phash else None,
artist=artist_dir,
@@ -61,11 +76,25 @@ def import_images_task(source_dir, dest_dir):
imported.append(filename)
# Add delay between each image
db.session.commit()
time.sleep(1)
db.session.commit()
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):
hash_func = hashlib.sha256()
@@ -74,7 +103,6 @@ def calculate_hash(file_path):
hash_func.update(chunk)
return hash_func.hexdigest()
def calculate_perceptual_hash(file_path):
try:
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}")
return None
def is_similar_image(phash, width, height, threshold=2):
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all():
try:
@@ -96,7 +123,6 @@ def is_similar_image(phash, width, height, threshold=2):
print(f"[WARN] Failed pHash comparison: {e}")
return False
def extract_metadata(file_path):
metadata = {
'file_size': None,
@@ -129,4 +155,4 @@ def extract_metadata(file_path):
except Exception as e:
print(f"[WARN] Failed to read EXIF data: {file_path} {e}")
return metadata
return metadata
+1 -2
View File
@@ -7,5 +7,4 @@ services:
- ./imagerepo/db/:/db # SQLite db storage
- ./imagerepo/images:/images # where the app will store it's imported images
- ./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 os
from app import create_app
from app.utils.image_importer import import_images_task
THUMBNAIL_FLAG = "/import/thumbnail.flag"
TRIGGER_FLAG = "/import/trigger.flag"
SOURCE_DIR = "/import"
DEST_DIR = "/images"
@@ -29,8 +30,29 @@ def monitor_and_import():
print("[Image Importer] Trigger flag cleared.")
except Exception as 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)
@@ -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 ###