// /app/static/js/modal-pagination.js
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalClose = document.getElementById('modalClose');
const modalImageWrapper = document.querySelector('.modal-image-wrapper');
// NEW: tag editor elements
const tagEditor = document.getElementById('modalTagEditor');
const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
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;
// Legacy pagination inputs (for non-infinite-scroll pages)
const hasNextPageInput = document.getElementById('hasNextPage');
const nextPageUrlInput = document.getElementById('nextPageUrl');
const hasPrevPageInput = document.getElementById('hasPrevPage');
const prevPageUrlInput = document.getElementById('prevPageUrl');
// Check if we're in infinite scroll mode
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
// ---------------------------
// Tag editor helpers
// ---------------------------
function setEditorImageId(id) {
if (tagEditor) tagEditor.dataset.imageId = id || '';
}
function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : '';
}
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
function getTagDisplayName(name) {
// Remove prefix if present (e.g., "artist:Name" -> "Name")
return name.includes(':') ? name.split(':', 2)[1] : name;
}
function renderTags(tags) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
chip.innerHTML = `${icon} ${displayName} `;
tagList.appendChild(chip);
});
}
async function loadTags(imageId) {
if (!imageId) { renderTags([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) renderTags(j.tags);
} catch {
// ignore
}
}
async function addTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
return true;
}
return false;
}
async function removeTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
return true;
}
return false;
}
// ---------------------------
// Autocomplete helpers
// ---------------------------
function hideAutocomplete() {
if (tagAutocomplete) {
tagAutocomplete.classList.remove('active');
tagAutocomplete.innerHTML = '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function positionAutocomplete() {
if (!tagAutocomplete || !tagInput) return;
const rect = tagInput.getBoundingClientRect();
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
tagAutocomplete.style.left = `${rect.left}px`;
tagAutocomplete.style.width = `${rect.width}px`;
}
function renderAutocomplete(tags) {
if (!tagAutocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
tagAutocomplete.innerHTML = '
No matching tags
';
positionAutocomplete();
tagAutocomplete.classList.add('active');
return;
}
tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
return `
${icon} ${displayName}
${t.kind || 'user'}
`;
}).join('');
positionAutocomplete();
tagAutocomplete.classList.add('active');
}
function selectAutocompleteItem(index) {
const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
async function fetchAutocomplete(query) {
try {
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
const excludeKinds = 'archive,post,source';
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
hideAutocomplete();
}
}
// Shared function to submit a tag (avoids page refresh issues with form dispatch)
async function submitTag() {
const id = getEditorImageId();
const name = (tagInput?.value || '').trim();
if (!id || !name) return;
hideAutocomplete();
await addTag(id, name);
if (tagInput) tagInput.value = '';
}
if (tagInput) {
// Show autocomplete on focus
tagInput.addEventListener('focus', () => {
const val = tagInput.value.trim();
fetchAutocomplete(val);
});
// Hide autocomplete on blur (with delay for click)
tagInput.addEventListener('blur', () => {
setTimeout(hideAutocomplete, 200);
});
// Search as user types
tagInput.addEventListener('input', () => {
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(tagInput.value.trim());
}, 150);
});
// Keyboard navigation
tagInput.addEventListener('keydown', (e) => {
if (!tagAutocomplete?.classList.contains('active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
tagInput.value = selected.name;
submitTag();
}
} else if (e.key === 'Escape') {
hideAutocomplete();
}
});
}
// Click on autocomplete item - use mousedown to fire before blur
if (tagAutocomplete) {
tagAutocomplete.addEventListener('mousedown', (e) => {
e.preventDefault(); // Prevent blur from firing
e.stopPropagation();
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
if (tagInput && name) {
tagInput.value = name;
submitTag();
}
});
}
if (tagForm) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
await submitTag();
});
}
if (tagList) {
tagList.addEventListener('click', async (e) => {
const btn = e.target.closest('button.x');
if (!btn) return;
const id = getEditorImageId();
const name = btn.dataset.name;
if (!id || !name) return;
await removeTag(id, name);
});
}
// ---------------------------
// Existing viewer logic
// ---------------------------
function updateImage(index) {
const img = images[index];
const fileType = img.dataset.type || "image";
const imageId = img.dataset.id || "";
// clear wrapper
modalImageWrapper.innerHTML = "";
if (fileType === "video") {
const video = document.createElement("video");
video.src = img.dataset.full;
video.controls = true;
video.autoplay = true;
video.style.maxWidth = "100%";
video.style.maxHeight = "100%";
modalImageWrapper.appendChild(video);
} else {
const image = document.createElement("img");
image.src = img.dataset.full;
image.alt = "Full View";
image.setAttribute("draggable", "false");
image.id = "modalImage";
modalImageWrapper.appendChild(image);
}
currentIndex = index;
resetZoom();
// NEW: load tags for this image into the modal editor
setEditorImageId(imageId);
loadTags(imageId);
}
function openModal(index) {
modal.classList.add('active');
updateImage(index);
history.replaceState({ modalIndex: index }, '', window.location.href);
// Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile)
if (tagInput && !isTouchDevice()) {
setTimeout(() => tagInput.focus(), 100);
}
}
// Detect touch devices to avoid auto-focus keyboard popup
function isTouchDevice() {
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
}
function closeModal() {
modal.classList.remove('active');
modalImageWrapper.innerHTML = '';
currentIndex = -1;
resetZoom();
// clear tag UI
setEditorImageId('');
renderTags([]);
history.replaceState({}, '', window.location.pathname + window.location.search);
}
function showNext() {
if (currentIndex < images.length - 1) {
updateImage(currentIndex + 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the last image
// User needs to scroll the gallery to load more
return;
} else if (hasNextPageInput?.value === "true") {
// Legacy pagination mode - fetch next page
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));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', nextPageUrlInput.value);
openModal(0);
}
});
}
}
function showPrev() {
if (currentIndex > 0) {
updateImage(currentIndex - 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the first image
return;
} else if (hasPrevPageInput?.value === "true") {
// Legacy pagination mode - fetch previous page
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));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', prevPageUrlInput.value);
openModal(images.length - 1);
}
});
}
}
function toggleZoom() {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isImage) return;
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) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isZoomed || !isImage) 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;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mouseup', () => {
isDragging = false;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mousemove', (e) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isDragging || !isZoomed || !isImage) 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();
});
// Use event delegation on the document for .img-clickable clicks
// This allows dynamically added elements (e.g., from shuffle) to work
document.addEventListener('click', (e) => {
const clickable = e.target.closest('.img-clickable');
if (!clickable) return;
// Re-query images to get current state (may have changed from shuffle)
images = Array.from(document.querySelectorAll('.img-clickable'));
const index = images.indexOf(clickable);
if (index !== -1) {
openModal(index);
}
});
// Listen for showcase updates to refresh the images array
document.addEventListener('showcaseUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
// Listen for gallery infinite scroll updates to refresh the images array
document.addEventListener('galleryUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
window.addEventListener('popstate', () => {
if (modal.classList.contains('active')) {
closeModal();
} else {
location.reload();
}
});
});