475 lines
15 KiB
JavaScript
475 lines
15 KiB
JavaScript
// /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;
|
||
|
||
const hasNextPageInput = document.getElementById('hasNextPage');
|
||
const nextPageUrlInput = document.getElementById('nextPageUrl');
|
||
const hasPrevPageInput = document.getElementById('hasPrevPage');
|
||
const prevPageUrlInput = document.getElementById('prevPageUrl');
|
||
|
||
// ---------------------------
|
||
// 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: '⚠️'
|
||
};
|
||
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} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
|
||
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 renderAutocomplete(tags) {
|
||
if (!tagAutocomplete) return;
|
||
autocompleteItems = tags;
|
||
autocompleteSelectedIndex = -1;
|
||
|
||
if (tags.length === 0) {
|
||
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
|
||
tagAutocomplete.classList.add('active');
|
||
return;
|
||
}
|
||
|
||
tagAutocomplete.innerHTML = tags.map((t, i) => {
|
||
const icon = getTagIcon(t.kind);
|
||
const displayName = getTagDisplayName(t.name);
|
||
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}">
|
||
<span>${icon} ${displayName}</span>
|
||
<span class="tag-kind">${t.kind || 'user'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
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 {
|
||
const url = query
|
||
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8`
|
||
: `/api/tags/search?limit=8`;
|
||
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
|
||
if (tagAutocomplete) {
|
||
tagAutocomplete.addEventListener('click', (e) => {
|
||
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);
|
||
}
|
||
|
||
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 (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));
|
||
});
|
||
|
||
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 (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));
|
||
});
|
||
|
||
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'));
|
||
});
|
||
|
||
window.addEventListener('popstate', () => {
|
||
if (modal.classList.contains('active')) {
|
||
closeModal();
|
||
} else {
|
||
location.reload();
|
||
}
|
||
});
|
||
});
|