// /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(); } }); });