diff --git a/public/css/backgrounds.css b/public/css/backgrounds.css index e08ea31ca..bd8cab547 100644 --- a/public/css/backgrounds.css +++ b/public/css/backgrounds.css @@ -136,19 +136,32 @@ outline-offset: 0; } -.bg_example.locked-background::after { - content: '\f023'; +.bg_example.locked-background::after, +.bg_example.selected-background::before, +#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before { font-family: 'Font Awesome 6 Free'; font-weight: 900; - position: absolute; + z-index: 4; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8)); + pointer-events: none; +} + +.bg_example.selected-background::before, +#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before { + content: '\f00c'; + top: 5px; + color: var(--white100); + font-size: calc(var(--mainFontSize) * 0.9); +} + +.bg_example.locked-background::after { + content: '\f023'; bottom: 5px; right: 5px; - z-index: 4; color: var(--golden); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8)); font-size: calc(var(--mainFontSize) * 0.8); - pointer-events: none; } .bg_example:not(.locked-background) .jg-unlock, @@ -162,17 +175,28 @@ } .bg_example.selected-background::before { - content: '\f00c'; - font-family: 'Font Awesome 6 Free'; - font-weight: 900; - position: absolute; - top: 5px; left: 5px; - z-index: 4; - color: var(--white100); - filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8)); - font-size: calc(var(--mainFontSize) * 0.9); - pointer-events: none; +} + +#bg_selection_mode_button.active { + color: var(--golden); +} + +#bg_group_select_count { + font-weight: 700; +} + +#Backgrounds.bg-selection-mode #bg_menu_content .bg_example .jg-menu { + display: none; +} + +#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected { + outline: 2px solid var(--interactable-outline-color); + outline-offset: 0; +} + +#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before { + right: 5px; } .bg_example .jg-menu { diff --git a/public/css/mobile-styles.css b/public/css/mobile-styles.css index 7e6a6da7b..845c32f23 100644 --- a/public/css/mobile-styles.css +++ b/public/css/mobile-styles.css @@ -153,10 +153,30 @@ #add_background_button_top>span, #auto_background>span, - #bg_add_folder_button>span { + #bg_add_folder_button>span, + #bg_selection_mode_button>span:not(#bg_group_select_count), + #bg_group_add_to_folder_button>span, + #bg_folder_remove_selected_button>span { display: none; } + #bg_group_select_count:not(:empty) { + display: inline !important; + } + + .bg_folder_breadcrumb { + gap: 5px; + } + + .bg_folder_breadcrumb .expander { + min-width: 0; + } + + #bg_current_folder_name { + min-width: 0; + flex: 1 1 auto; + } + #extensions_settings, #extensions_settings2 { width: 100% !important; diff --git a/public/index.html b/public/index.html index 5e44008c9..13b61e0dc 100644 --- a/public/index.html +++ b/public/index.html @@ -5635,6 +5635,14 @@ Chat
+ + @@ -5650,6 +5658,11 @@ Back + +
diff --git a/public/scripts/backgrounds.js b/public/scripts/backgrounds.js index 5b0158a64..1d1d0f78d 100644 --- a/public/scripts/backgrounds.js +++ b/public/scripts/backgrounds.js @@ -20,6 +20,10 @@ let folderList = []; let imageFolderMap = {}; /** @type {string|null} Currently active folder drill-in, or null for root */ let activeFolderId = null; +/** @type {Set} Selected system backgrounds for group folder actions */ +const selectedSystemBackgroundFiles = new Set(); +/** @type {boolean} Whether click-to-select mode is active for system backgrounds */ +let isBackgroundSelectionMode = false; // A single transparent PNG pixel used as a placeholder for errored backgrounds const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; @@ -365,6 +369,11 @@ function removeBackgroundMetadata() { function onSelectBackgroundClick(e) { const bgFile = $(this).attr('bgfile'); const isCustom = $(this).attr('custom') === 'true'; + if (isBackgroundSelectionMode && !isCustom) { + toggleBackgroundGroupSelection(bgFile); + return; + } + const backgroundCssUrl = getUrlParameter(this); const bypassGlobalLock = !isCustom && e.shiftKey; @@ -576,6 +585,7 @@ async function onDeleteBackgroundClick(e) { if (deletedBg) { const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg); if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1); + selectedSystemBackgroundFiles.delete(deletedBg); // Update folder map and clear folder thumbnail if it referenced this image if (imageFolderMap[deletedBg]) { @@ -605,6 +615,7 @@ async function onDeleteBackgroundClick(e) { highlightLockedBackground(); highlightSelectedBackground(); + syncGroupSelectionUi(); } } @@ -652,7 +663,10 @@ function renderSystemBackgrounds(backgrounds) { const container = $('#bg_menu_content'); container.empty(); - if (sourceList.length === 0) return; + if (sourceList.length === 0) { + syncGroupSelectionUi(); + return; + } const sortedList = sortBackgrounds(sourceList.map(bg => bg.filename), false); const metadataByFilename = new Map(sourceList.map(bg => [bg.filename, bg])); @@ -663,6 +677,7 @@ function renderSystemBackgrounds(backgrounds) { container.append(thumbnail); }); + syncGroupSelectionUi(); activateLazyLoader(); } @@ -700,6 +715,12 @@ export async function getBackgrounds() { const { images, config } = await response.json(); Object.assign(THUMBNAIL_CONFIG, config); cachedSystemBackgrounds = images; + const existingFiles = new Set(images.map(x => x.filename)); + for (const selectedFile of selectedSystemBackgroundFiles) { + if (!existingFiles.has(selectedFile)) { + selectedSystemBackgroundFiles.delete(selectedFile); + } + } // Load folders first so getFilteredImages() works correctly in folder view await loadFolders(); @@ -859,6 +880,7 @@ function onFolderDrillIn(folderId) { const folder = folderList.find(f => f.id === folderId); if (!folder) return; + clearBackgroundGroupSelection(); activeFolderId = folderId; $('#Backgrounds').addClass('in-folder-view'); @@ -876,6 +898,7 @@ function onFolderDrillIn(folderId) { * Returns to the root folder overview. */ function onBackToFolders() { + clearBackgroundGroupSelection(); activeFolderId = null; $('#Backgrounds').removeClass('in-folder-view'); @@ -889,6 +912,245 @@ function onBackToFolders() { highlightSelectedBackground(); } +/** + * Refreshes click-to-select and group action UI state. + */ +function syncGroupSelectionUi() { + const selectedCount = selectedSystemBackgroundFiles.size; + const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL; + const showAddButton = isGlobalTab && isBackgroundSelectionMode && selectedCount > 0; + const showRemoveFromCurrentFolderButton = isGlobalTab && Boolean(activeFolderId) && isBackgroundSelectionMode && selectedCount > 0; + + $('#Backgrounds').toggleClass('bg-selection-mode', isBackgroundSelectionMode); + $('#bg_selection_mode_button').toggleClass('active', isBackgroundSelectionMode); + $('#bg_group_select_count').text(selectedCount > 0 ? ` (${selectedCount})` : '').toggle(selectedCount > 0); + + $('#bg_group_add_to_folder_button').toggle(showAddButton); + $('#bg_folder_remove_selected_button').toggle(showRemoveFromCurrentFolderButton); + + $('#bg_menu_content .bg_example').each(function () { + const bgFile = String($(this).attr('bgfile') || ''); + $(this).toggleClass('folder-group-selected', selectedSystemBackgroundFiles.has(bgFile)); + }); +} + +/** + * Enables/disables click-to-select mode for system backgrounds. + * @param {boolean} enabled + */ +function setBackgroundSelectionMode(enabled) { + isBackgroundSelectionMode = enabled; + if (!enabled) { + selectedSystemBackgroundFiles.clear(); + } + // Clear any open mobile menus + $('#bg_menu_content .bg_example.mobile-menu-open').removeClass('mobile-menu-open'); + syncGroupSelectionUi(); +} + +/** + * Toggles selected state of a system background for group folder actions. + * @param {string} bgFile + */ +function toggleBackgroundGroupSelection(bgFile) { + if (!bgFile) return; + if (selectedSystemBackgroundFiles.has(bgFile)) { + selectedSystemBackgroundFiles.delete(bgFile); + } else { + selectedSystemBackgroundFiles.add(bgFile); + } + syncGroupSelectionUi(); +} + +/** + * Clears all selected system backgrounds for group folder actions. + */ +function clearBackgroundGroupSelection() { + selectedSystemBackgroundFiles.clear(); + syncGroupSelectionUi(); +} + +/** + * Updates selection/folder action control visibility for the active tab. + */ +function updateGroupFolderControlsVisibility() { + const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL; + $('#bg_selection_mode_button').toggle(isGlobalTab); + + if (!isGlobalTab && isBackgroundSelectionMode) { + setBackgroundSelectionMode(false); + return; + } + syncGroupSelectionUi(); +} + +/** + * Shows a folder selection popup and returns the selected folder id. + * @param {string} headingText + * @returns {Promise} Array of selected folder IDs, or null if cancelled + */ +async function selectFoldersForGroupAction(headingText) { + if (folderList.length === 0) { + toastr.info(t`Create a folder first`); + return null; + } + + const contentEl = document.createElement('div'); + const heading = document.createElement('h3'); + heading.textContent = headingText; + contentEl.appendChild(heading); + + for (const folder of folderList) { + const label = document.createElement('label'); + label.className = 'checkbox_label flexGap5'; + label.style.margin = '4px 0'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.folderId = folder.id; + + const span = document.createElement('span'); + span.textContent = folder.name; + + label.appendChild(checkbox); + label.appendChild(span); + contentEl.appendChild(label); + } + + const content = $(contentEl); + const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { + okButton: t`Apply`, + cancelButton: t`Cancel`, + allowVerticalScrolling: true, + leftAlign: true, + }); + if (!result) return null; + + const selectedIds = []; + content.find('input[type="checkbox"]:checked').each(function () { + selectedIds.push($(this).data('folderId')); + }); + return selectedIds.length > 0 ? selectedIds : null; +} + +/** + * Sends a folder assign/unassign request and updates local imageFolderMap state. + * @param {string[]} bgFiles - Background filenames to update + * @param {string} folderId - Target folder ID + * @param {boolean} isRemove - Whether to remove (unassign) or add (assign) + */ +async function updateFolderAssignments(bgFiles, folderId, isRemove) { + const paths = bgFiles.map(getBackgroundRelativePath); + const endpoint = isRemove ? '/api/image-metadata/folders/unassign' : '/api/image-metadata/folders/assign'; + + const response = await fetch(endpoint, { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ id: folderId, paths }), + }); + + if (!response.ok) { + throw new Error(`Folder ${isRemove ? 'unassign' : 'assign'} failed: ${response.status}`); + } + + for (const bgFile of bgFiles) { + const currentFolderIds = imageFolderMap[bgFile] || []; + if (isRemove) { + const nextFolderIds = currentFolderIds.filter(id => id !== folderId); + if (nextFolderIds.length > 0) { + imageFolderMap[bgFile] = nextFolderIds; + } else { + delete imageFolderMap[bgFile]; + } + } else if (!currentFolderIds.includes(folderId)) { + imageFolderMap[bgFile] = [...currentFolderIds, folderId]; + } + } +} + +/** + * Adds selected system backgrounds to a chosen folder. + */ +async function onAddSelectedToFolder() { + if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) { + toastr.warning(t`Folder actions are only available in the Global tab`); + return; + } + + const bgFiles = Array.from(selectedSystemBackgroundFiles); + if (bgFiles.length === 0) { + toastr.info(t`Select one or more backgrounds first`); + return; + } + + const folderIds = await selectFoldersForGroupAction(t`Add selected backgrounds to folders`); + if (!folderIds) return; + + try { + let totalAdded = 0; + for (const folderId of folderIds) { + const actionableBgFiles = bgFiles.filter(bgFile => { + const currentFolderIds = imageFolderMap[bgFile] || []; + return !currentFolderIds.includes(folderId); + }); + if (actionableBgFiles.length > 0) { + await updateFolderAssignments(actionableBgFiles, folderId, false); + totalAdded += actionableBgFiles.length; + } + } + + renderFolderGrid(); + + if (activeFolderId) { + renderSystemBackgrounds(getFilteredImages()); + highlightSelectedBackground(); + } + + setBackgroundSelectionMode(false); + if (totalAdded > 0) { + toastr.success(t`Added backgrounds to ${folderIds.length} folder(s)`); + } else { + toastr.info(t`Selected backgrounds are already in the chosen folders`); + } + } catch (error) { + console.error('Error adding selected backgrounds to folder:', error); + toastr.error(t`Failed to update folder assignment`); + } +} + +/** + * Removes selected system backgrounds from the currently drilled-in folder. + */ +async function onRemoveSelectedFromCurrentFolder() { + if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) { + toastr.warning(t`Folder actions are only available in the Global tab`); + return; + } + + if (!activeFolderId) { + toastr.info(t`Open a folder first`); + return; + } + + const bgFiles = Array.from(selectedSystemBackgroundFiles); + if (bgFiles.length === 0) { + toastr.info(t`Select one or more backgrounds first`); + return; + } + + try { + await updateFolderAssignments(bgFiles, activeFolderId, true); + renderFolderGrid(); + renderSystemBackgrounds(getFilteredImages()); + highlightSelectedBackground(); + setBackgroundSelectionMode(false); + toastr.success(t`Removed ${bgFiles.length} background(s) from folder`); + } catch (error) { + console.error('Error removing selected backgrounds from current folder:', error); + toastr.error(t`Failed to update folder assignment`); + } +} + /** * Creates a new folder via API. */ @@ -1037,40 +1299,12 @@ async function onAssignToFolder(bgFile) { if (!isChecked && wasChecked) toUnassign.push(fid); }); - const relativePath = getBackgroundRelativePath(bgFile); - try { for (const fid of toAssign) { - const resp = await fetch('/api/image-metadata/folders/assign', { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify({ id: fid, paths: [relativePath] }), - }); - if (!resp.ok) throw new Error(`Assign to folder ${fid} failed: ${resp.status}`); + await updateFolderAssignments([bgFile], fid, false); } for (const fid of toUnassign) { - const resp = await fetch('/api/image-metadata/folders/unassign', { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify({ id: fid, paths: [relativePath] }), - }); - if (!resp.ok) throw new Error(`Unassign from folder ${fid} failed: ${resp.status}`); - } - - // Update local state - const newFolderIds = folderList - .filter(f => { - const wasIn = currentFolderIds.includes(f.id); - if (toAssign.includes(f.id)) return true; - if (toUnassign.includes(f.id)) return false; - return wasIn; - }) - .map(f => f.id); - - if (newFolderIds.length > 0) { - imageFolderMap[bgFile] = newFolderIds; - } else { - delete imageFolderMap[bgFile]; + await updateFolderAssignments([bgFile], fid, true); } renderFolderGrid(); @@ -1449,7 +1683,11 @@ const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debou * @returns {BG_SOURCES} Active background tab source */ export function getActiveBackgroundTab() { - return $('#bg_tabs').tabs('option', 'active'); + const tabs = $('#bg_tabs'); + if (!tabs.length || !tabs.data('ui-tabs')) { + return BG_SOURCES.GLOBAL; + } + return tabs.tabs('option', 'active'); } export function initBackgrounds() { @@ -1511,6 +1749,9 @@ export function initBackgrounds() { }) .off('click', '.jg-button').on('click', '.jg-button', function (e) { e.stopPropagation(); + if (isBackgroundSelectionMode && $(this).closest('#bg_menu_content').length) { + return; + } const action = $(this).data('action'); switch (action) { @@ -1553,6 +1794,9 @@ export function initBackgrounds() { applyThumbnailColumns(background_settings.thumbnailColumns + 1); }); $('#auto_background').on('click', autoBackgroundCommand); + $('#bg_selection_mode_button').on('click', () => setBackgroundSelectionMode(!isBackgroundSelectionMode)); + $('#bg_group_add_to_folder_button').on('click', onAddSelectedToFolder); + $('#bg_folder_remove_selected_button').on('click', onRemoveSelectedFromCurrentFolder); $('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent)); $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput()); $('#bg-sort').on('change', function () { @@ -1615,4 +1859,7 @@ export function initBackgrounds() { }); $('#bg_tabs').tabs(); + $('#bg_tabs').on('tabsactivate', () => updateGroupFolderControlsVisibility()); + updateGroupFolderControlsVisibility(); + syncGroupSelectionUi(); }