improved Modal with zoom and pagination. also implementing thumbnail generation
This commit is contained in:
+1
-1
@@ -21,7 +21,7 @@ venv/
|
||||
.gitignore
|
||||
|
||||
# Ignore DB files
|
||||
app.db
|
||||
imagerepo/
|
||||
|
||||
# Ignore Docker related files
|
||||
.dockerignore
|
||||
|
||||
+42
-6
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ###
|
||||
Reference in New Issue
Block a user