This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/static/js/bulk-select.js
T
bvandeusen 27609043e8 refactor(js): tag-utils uses display_name and tag_id
getTagDisplayName is now a passthrough; post-refactor names are bare
and callers that need the character+fandom suffix pass tag.display_name
from the server. createTagChipHtml builds ?tag_id=<int> URLs and uses
display_name for the label. bulk-select.js callers pass display_name
directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:19:52 -04:00

690 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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');
dom.bulkSuggestionsList = document.getElementById('bulkSuggestionsList');
dom.bulkSuggestionsHint = document.getElementById('bulkSuggestionsHint');
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();
loadBulkSuggestions();
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();
clearBulkSuggestions();
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 = '<span class="text-muted">No images selected</span>';
}
}
}
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 = '<span class="text-muted">No common tags</span>';
return;
}
dom.commonTags.innerHTML = tags.map(t => {
const icon = getTagIcon(t.kind);
const display = t.display_name || t.name;
const escapedName = escapeHtml(t.name);
const escapedDisplay = escapeHtml(display);
return `<span class="tag-chip removable" data-name="${escapedName}">
${icon} ${escapedDisplay}
<button class="x" title="Remove from all selected">×</button>
</span>`;
}).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);
});
});
}
// ---------------------------
// Bulk ML suggestions (consensus across selection)
// ---------------------------
const BULK_SUGGESTIONS_MIN = 2;
const BULK_SUGGESTIONS_THRESHOLD = 0.8;
const BULK_SUGGESTIONS_CATEGORY_ORDER = ['character', 'copyright', 'general'];
const BULK_SUGGESTIONS_CATEGORY_LABEL = {
character: 'Characters',
copyright: 'Fandoms',
general: 'General',
};
function clearBulkSuggestions() {
if (dom.bulkSuggestionsList) dom.bulkSuggestionsList.innerHTML = '';
setBulkSuggestionsHint('Select 2+ images to see consensus suggestions.');
}
function setBulkSuggestionsHint(text, visible = true) {
if (!dom.bulkSuggestionsHint) return;
dom.bulkSuggestionsHint.textContent = text;
dom.bulkSuggestionsHint.style.display = visible ? '' : 'none';
}
async function loadBulkSuggestions() {
if (!dom.bulkSuggestionsList) return;
const ids = Array.from(state.selectedIds);
if (ids.length < BULK_SUGGESTIONS_MIN) {
clearBulkSuggestions();
return;
}
setBulkSuggestionsHint('Loading…');
dom.bulkSuggestionsList.innerHTML = '';
try {
const r = await fetch('/api/suggestions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids, threshold: BULK_SUGGESTIONS_THRESHOLD }),
});
const j = await r.json();
if (!j.ok) {
setBulkSuggestionsHint('Could not load suggestions.');
return;
}
renderBulkSuggestions(j.suggestions || {}, ids.length);
} catch (e) {
setBulkSuggestionsHint('Could not load suggestions.');
}
}
function renderBulkSuggestions(grouped, selectionSize) {
if (!dom.bulkSuggestionsList) return;
dom.bulkSuggestionsList.innerHTML = '';
let total = 0;
for (const cat of BULK_SUGGESTIONS_CATEGORY_ORDER) {
const items = (grouped && grouped[cat]) || [];
if (items.length === 0) continue;
total += items.length;
const group = document.createElement('div');
group.className = 'suggestions-category';
group.dataset.category = cat;
const label = document.createElement('div');
label.className = 'suggestions-category-label';
label.textContent = BULK_SUGGESTIONS_CATEGORY_LABEL[cat] || cat;
group.appendChild(label);
const chips = document.createElement('div');
chips.className = 'suggestions-chips';
for (const item of items) {
chips.appendChild(buildBulkSuggestionChip(item, selectionSize));
}
group.appendChild(chips);
dom.bulkSuggestionsList.appendChild(group);
}
if (total === 0) {
setBulkSuggestionsHint('No consensus suggestions for this selection.');
} else {
setBulkSuggestionsHint('', false);
}
}
function buildBulkSuggestionChip(item, selectionSize) {
const chip = document.createElement('div');
chip.className = 'suggestion-chip suggestion-chip-bulk';
chip.dataset.tagName = item.name;
chip.dataset.category = item.category;
const pct = Math.round((item.confidence || 0) * 100);
const covered = item.covered_count != null ? item.covered_count : selectionSize;
chip.title = `${item.name} — suggested for ${covered}/${selectionSize}`;
chip.innerHTML = `
<button type="button" class="sugg-btn sugg-accept" aria-label="Add to all selected">✓</button>
<span class="sugg-body">
<span class="sugg-label">${escapeHtml(item.name)}</span>
<span class="sugg-confidence">${pct}% · ${covered}/${selectionSize}</span>
</span>
`;
const acceptBtn = chip.querySelector('.sugg-accept');
if (acceptBtn) {
acceptBtn.addEventListener('click', () => acceptBulkSuggestion(chip));
}
return chip;
}
async function acceptBulkSuggestion(chip) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
chip.style.opacity = '0.5';
const name = chip.dataset.tagName;
try {
await bulkAddTag(name);
chip.classList.add('leaving');
setTimeout(() => chip.remove(), 200);
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
chip.style.opacity = '';
}
}
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 = '<div class="tag-autocomplete-empty">No matching tags</div>';
positionAutocomplete();
dom.autocomplete.classList.add('active');
return;
}
dom.autocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = t.display_name || t.name;
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${escapeHtml(t.name)}">
<span>${icon} ${escapeHtml(displayName)}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).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
})();