// /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'; // Use shared TagUtils module const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {}; const state = { isSelectMode: false, selectedIds: new Set(), selectionOrder: [] // Array to track order of selection }; // 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); // Remove from order array without creating new array (preserves window.selectionOrder reference) const idx = state.selectionOrder.indexOf(id); if (idx !== -1) state.selectionOrder.splice(idx, 1); item.classList.remove('selected'); removeOrderBadge(item); } else { state.selectedIds.add(id); state.selectionOrder.push(id); item.classList.add('selected'); addOrderBadge(item, state.selectionOrder.length); } // Update all badges to reflect current order updateAllOrderBadges(); updateCount(); loadCommonTags(); dispatchSelectionChanged(); } function clearSelection() { state.selectedIds.clear(); // Clear array without creating new one (preserves window.selectionOrder reference) state.selectionOrder.length = 0; document.querySelectorAll('.img-clickable.selected').forEach(el => { el.classList.remove('selected'); removeOrderBadge(el); }); updateCount(); updateCommonTagsDisplay(); dispatchSelectionChanged(); } function updateCount() { if (dom.countEl) { dom.countEl.textContent = state.selectedIds.size; } } function dispatchSelectionChanged() { document.dispatchEvent(new CustomEvent('selectionChanged', { detail: { count: state.selectedIds.size, orderedIds: [...state.selectionOrder] } })); } // Enable select mode programmatically, optionally without opening the bulk panel function enableSelectMode(skipPanel = false) { if (state.isSelectMode) return; state.isSelectMode = true; document.body.classList.add('select-mode'); dom.selectBtn.textContent = 'Cancel'; dom.selectBtn.classList.add('active'); if (!skipPanel) { dom.panel.classList.add('active'); updateCommonTagsDisplay(); } } // Disable select mode function disableSelectMode() { if (!state.isSelectMode) return; state.isSelectMode = false; document.body.classList.remove('select-mode'); dom.selectBtn.textContent = 'Select'; dom.selectBtn.classList.remove('active'); dom.panel.classList.remove('active'); clearSelection(); } function updateSelectionUI() { // Re-apply selected class and order badges 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'); const orderIndex = state.selectionOrder.indexOf(id); if (orderIndex !== -1) { addOrderBadge(item, orderIndex + 1); } } }); } // --------------------------- // Order Badge Helpers // --------------------------- function addOrderBadge(item, orderNumber) { removeOrderBadge(item); // Remove existing badge first const badge = document.createElement('div'); badge.className = 'selection-order-badge'; badge.textContent = orderNumber; item.appendChild(badge); } function removeOrderBadge(item) { const existing = item.querySelector('.selection-order-badge'); if (existing) existing.remove(); } function updateAllOrderBadges() { document.querySelectorAll('.img-clickable').forEach(item => { const id = parseInt(item.dataset.id); const orderIndex = state.selectionOrder.indexOf(id); if (orderIndex !== -1) { addOrderBadge(item, orderIndex + 1); } else { removeOrderBadge(item); } }); } 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 = getTagDisplayName(t.name, t.kind); 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 refreshSelectedItemsTags(); // Update tag display on gallery items } 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 refreshSelectedItemsTags(); // Update tag display on gallery items } 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(); const viewportWidth = window.innerWidth; const dropdownWidth = rect.width; // Calculate left position, ensuring dropdown doesn't overflow right edge let leftPos = rect.left; if (leftPos + dropdownWidth > viewportWidth - 8) { leftPos = viewportWidth - dropdownWidth - 8; } dom.autocomplete.style.top = `${rect.bottom + 4}px`; dom.autocomplete.style.left = `${leftPos}px`; dom.autocomplete.style.width = `${dropdownWidth}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 = getTagDisplayName(t.name, t.kind); 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; } // --------------------------- // Tag Refresh for Gallery Items // --------------------------- async function refreshItemTags(imageId) { // Only the gallery page renders tag overlays on thumbnails. Skip showcase items // (class .masonry-item) so closing the modal doesn't inject a .tag-overlay there. const item = document.querySelector(`.gallery-item[data-id="${imageId}"]`); if (!item) return; try { const r = await fetch(`/image/${imageId}/tags`); const data = await r.json(); if (!data.ok) return; // Filter out archive and source tags (they're not shown in overlays) const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive' && t.kind !== 'source'); // Find or create tag overlay let overlay = item.querySelector('.tag-overlay'); if (visibleTags.length === 0) { // No visible tags - remove overlay if exists if (overlay) overlay.remove(); return; } if (!overlay) { overlay = document.createElement('div'); overlay.className = 'tag-overlay'; item.appendChild(overlay); } // Render tags with icons using shared utility overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join(''); } catch (e) { console.error('Failed to refresh tags for image', imageId, e); } } async function refreshSelectedItemsTags() { // Refresh tags for all currently selected items const ids = Array.from(state.selectedIds); await Promise.all(ids.map(id => refreshItemTags(id))); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Expose global API for other scripts (like series editor, modal) window.selectedImages = state.selectedIds; window.selectionOrder = state.selectionOrder; // Ordered array of selected IDs window.clearSelection = clearSelection; window.enableSelectMode = enableSelectMode; window.disableSelectMode = disableSelectMode; window.refreshItemTags = refreshItemTags; // Refresh tags display on a single gallery item })();