// /app/static/js/bulk-select.js // Multi-select mode controller for bulk tag operations // Works with gallery-infinite.js and showcase.js (function() { 'use strict'; const state = { isSelectMode: false, selectedIds: new Set() }; // DOM elements (populated on init) const dom = {}; // Autocomplete state let autocompleteItems = []; let autocompleteSelectedIndex = -1; let autocompleteDebounce = null; function init() { dom.selectBtn = document.getElementById('selectModeBtn'); dom.panel = document.getElementById('bulkEditorPanel'); dom.closeBtn = document.getElementById('closeBulkEditor'); dom.countEl = document.getElementById('selectedCount'); dom.clearBtn = document.getElementById('clearSelectionBtn'); dom.addForm = document.getElementById('bulkAddTagForm'); dom.tagInput = document.getElementById('bulkTagInput'); dom.autocomplete = document.getElementById('bulkTagAutocomplete'); dom.commonTags = document.getElementById('commonTagsList'); if (!dom.selectBtn || !dom.panel) return; setupEventListeners(); } function setupEventListeners() { // Toggle select mode dom.selectBtn.addEventListener('click', toggleSelectMode); dom.closeBtn?.addEventListener('click', exitSelectMode); dom.clearBtn?.addEventListener('click', clearSelection); // Image click handling (delegated) - use capture phase to intercept before modal document.addEventListener('click', handleImageClick, true); // Bulk add tag form dom.addForm?.addEventListener('submit', handleBulkAddTag); // Tag autocomplete setupAutocomplete(); // Listen for gallery updates (new images loaded) document.addEventListener('galleryUpdated', updateSelectionUI); document.addEventListener('showcaseUpdated', updateSelectionUI); // Keyboard shortcut: Escape to exit select mode document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.isSelectMode) { // Only exit if modal is not open const modal = document.getElementById('imageModal'); if (!modal || !modal.classList.contains('active')) { exitSelectMode(); } } }); } function toggleSelectMode() { state.isSelectMode = !state.isSelectMode; document.body.classList.toggle('select-mode', state.isSelectMode); dom.selectBtn.textContent = state.isSelectMode ? 'Cancel' : 'Select'; dom.selectBtn.classList.toggle('active', state.isSelectMode); if (state.isSelectMode) { dom.panel.classList.add('active'); updateCommonTagsDisplay(); } else { dom.panel.classList.remove('active'); clearSelection(); } } function exitSelectMode() { if (state.isSelectMode) { toggleSelectMode(); } } function handleImageClick(e) { if (!state.isSelectMode) return; const item = e.target.closest('.img-clickable'); if (!item) return; // Don't interfere with tag chip clicks if (e.target.closest('.tag-chip')) return; // Prevent modal from opening in select mode e.stopPropagation(); e.preventDefault(); const id = parseInt(item.dataset.id); if (isNaN(id)) return; if (state.selectedIds.has(id)) { state.selectedIds.delete(id); item.classList.remove('selected'); } else { state.selectedIds.add(id); item.classList.add('selected'); } updateCount(); loadCommonTags(); } function clearSelection() { state.selectedIds.clear(); document.querySelectorAll('.img-clickable.selected').forEach(el => { el.classList.remove('selected'); }); updateCount(); updateCommonTagsDisplay(); } function updateCount() { if (dom.countEl) { dom.countEl.textContent = state.selectedIds.size; } } function updateSelectionUI() { // Re-apply selected class to any newly loaded images that are in selection document.querySelectorAll('.img-clickable').forEach(item => { const id = parseInt(item.dataset.id); if (state.selectedIds.has(id)) { item.classList.add('selected'); } }); } function updateCommonTagsDisplay() { if (state.selectedIds.size === 0) { if (dom.commonTags) { dom.commonTags.innerHTML = 'No images selected'; } } } async function loadCommonTags() { if (state.selectedIds.size === 0) { updateCommonTagsDisplay(); return; } const ids = Array.from(state.selectedIds); try { const response = await fetch('/api/images/common-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_ids: ids }) }); const data = await response.json(); if (data.ok) { renderCommonTags(data.tags); } } catch (err) { console.error('Failed to load common tags:', err); } } function renderCommonTags(tags) { if (!dom.commonTags) return; if (!tags || tags.length === 0) { dom.commonTags.innerHTML = 'No common tags'; return; } dom.commonTags.innerHTML = tags.map(t => { const icon = getTagIcon(t.kind); const display = t.name.includes(':') ? t.name.split(':')[1] : t.name; const escapedName = escapeHtml(t.name); const escapedDisplay = escapeHtml(display); return ` ${icon} ${escapedDisplay} `; }).join(''); // Add remove handlers dom.commonTags.querySelectorAll('.tag-chip .x').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const chip = e.target.closest('.tag-chip'); const name = chip.dataset.name; chip.style.opacity = '0.5'; await bulkRemoveTag(name); }); }); } async function handleBulkAddTag(e) { e.preventDefault(); const name = dom.tagInput.value.trim(); if (!name || state.selectedIds.size === 0) return; dom.tagInput.disabled = true; await bulkAddTag(name); dom.tagInput.value = ''; dom.tagInput.disabled = false; dom.tagInput.focus(); hideAutocomplete(); } async function bulkAddTag(name) { const ids = Array.from(state.selectedIds); try { const response = await fetch('/api/images/bulk-add-tag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_ids: ids, tag_name: name }) }); const data = await response.json(); if (data.ok) { loadCommonTags(); // Refresh common tags } else { console.error('Failed to add tag:', data.error); } } catch (err) { console.error('Failed to add tag:', err); } } async function bulkRemoveTag(name) { const ids = Array.from(state.selectedIds); try { const response = await fetch('/api/images/bulk-remove-tag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_ids: ids, tag_name: name }) }); const data = await response.json(); if (data.ok) { loadCommonTags(); // Refresh common tags } else { console.error('Failed to remove tag:', data.error); } } catch (err) { console.error('Failed to remove tag:', err); } } // --------------------------- // Autocomplete // --------------------------- function setupAutocomplete() { if (!dom.tagInput) return; // Move autocomplete dropdown to body to escape transform containing block // (CSS transform on parent creates new stacking context that breaks fixed positioning) if (dom.autocomplete && dom.autocomplete.parentNode !== document.body) { document.body.appendChild(dom.autocomplete); } if (!dom.autocomplete) return; // Show autocomplete on focus dom.tagInput.addEventListener('focus', () => { const val = dom.tagInput.value.trim(); fetchAutocomplete(val); }); // Hide autocomplete on blur (with delay for click) dom.tagInput.addEventListener('blur', () => { setTimeout(hideAutocomplete, 200); }); // Search as user types dom.tagInput.addEventListener('input', () => { clearTimeout(autocompleteDebounce); autocompleteDebounce = setTimeout(() => { fetchAutocomplete(dom.tagInput.value.trim()); }, 150); }); // Keyboard navigation dom.tagInput.addEventListener('keydown', (e) => { if (!dom.autocomplete.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) { dom.tagInput.value = selected.name; hideAutocomplete(); // Submit the form dom.addForm.dispatchEvent(new Event('submit', { cancelable: true })); } } else if (e.key === 'Escape') { hideAutocomplete(); } }); // Click on autocomplete item dom.autocomplete.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); const item = e.target.closest('.tag-autocomplete-item'); if (!item) return; const name = item.dataset.name; if (name) { dom.tagInput.value = name; hideAutocomplete(); dom.addForm.dispatchEvent(new Event('submit', { cancelable: true })); } }); } function hideAutocomplete() { if (dom.autocomplete) { dom.autocomplete.classList.remove('active'); dom.autocomplete.innerHTML = ''; } autocompleteItems = []; autocompleteSelectedIndex = -1; } function positionAutocomplete() { if (!dom.autocomplete || !dom.tagInput) return; const rect = dom.tagInput.getBoundingClientRect(); dom.autocomplete.style.top = `${rect.bottom + 4}px`; dom.autocomplete.style.left = `${rect.left}px`; dom.autocomplete.style.width = `${rect.width}px`; } async function fetchAutocomplete(query) { try { // Exclude system-managed tag kinds from autocomplete 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(); } } function renderAutocomplete(tags) { if (!dom.autocomplete) return; autocompleteItems = tags; autocompleteSelectedIndex = -1; if (tags.length === 0) { dom.autocomplete.innerHTML = '
No matching tags
'; positionAutocomplete(); dom.autocomplete.classList.add('active'); return; } dom.autocomplete.innerHTML = tags.map((t, i) => { const icon = getTagIcon(t.kind); const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; return `
${icon} ${escapeHtml(displayName)} ${t.kind || 'user'}
`; }).join(''); positionAutocomplete(); dom.autocomplete.classList.add('active'); } function selectAutocompleteItem(index) { const items = dom.autocomplete?.querySelectorAll('.tag-autocomplete-item'); if (!items) return; items.forEach((el, i) => { el.classList.toggle('selected', i === index); }); autocompleteSelectedIndex = index; } // --------------------------- // Helpers // --------------------------- function getTagIcon(kind) { const icons = { artist: '🎨', archive: '🗜️', character: '👤', series: '📺', rating: '⚠️', source: '🌐', post: '📌' }; return icons[kind] || '#'; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();