Group add to background folder (#5237) * group add * make button not wide * make single and batch add to folder use the same style popup * prevent stuck UI, allow scrolling, prevent ::after conflicts * extremely large commit, beware * clear everything after performing bulk actions * Unify bulk selection design language --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

90892fd2367363ff1211ffbc6656e8cac79d56a2

Lucas Scala <123923688+Vibecoder9000@users.noreply.github.com>

Signed
4 files changed, +352 -48Ignore whitespace
public/css/backgrounds.css+39 -15
@@ -136,19 +136,32 @@
136136 outline-offset: 0;
137137}
138138
139139.bg_example.locked-background::after {,
140- content: '\f023';
140+.bg_example.selected-background::before,
141+#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before {
141142 font-family: 'Font Awesome 6 Free';
142143 font-weight: 900;
143-
144144 position: absolute;
145+ z-index: 4;
146+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
147+ pointer-events: none;
148+}
149+
150+.bg_example.selected-background::before,
151+#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before {
152+ content: '\f00c';
153+ top: 5px;
154+ color: var(--white100);
155+ font-size: calc(var(--mainFontSize) * 0.9);
156+}
157+
158+.bg_example.locked-background::after {
159+ content: '\f023';
145160 bottom: 5px;
146161 right: 5px;
147- z-index: 4;
148162 color: var(--golden);
149163 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
150164 font-size: calc(var(--mainFontSize) * 0.8);
151- pointer-events: none;
152165}
153166
154167.bg_example:not(.locked-background) .jg-unlock,
@@ -162,17 +175,28 @@
162175}
163176
164177.bg_example.selected-background::before {
165- content: '\f00c';
166- font-family: 'Font Awesome 6 Free';
167- font-weight: 900;
168- position: absolute;
169- top: 5px;
170178 left: 5px;
171- z-index: 4;
179+}
172- color: var(--white100);
180+
173- filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
181+#bg_selection_mode_button.active {
174182 font-sizecolor: calc(var(--mainFontSize) * 0.9golden);
175- pointer-events: none;
183+}
184+
185+#bg_group_select_count {
186+ font-weight: 700;
187+}
188+
189+#Backgrounds.bg-selection-mode #bg_menu_content .bg_example .jg-menu {
190+ display: none;
191+}
192+
193+#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected {
194+ outline: 2px solid var(--interactable-outline-color);
195+ outline-offset: 0;
196+}
197+
198+#Backgrounds.bg-selection-mode #bg_menu_content .bg_example.folder-group-selected::before {
199+ right: 5px;
176200}
177201
178202.bg_example .jg-menu {
public/css/mobile-styles.css+21 -1
@@ -153,10 +153,30 @@
153153
154154 #add_background_button_top>span,
155155 #auto_background>span,
156156 #bg_add_folder_button>span {,
157+ #bg_selection_mode_button>span:not(#bg_group_select_count),
158+ #bg_group_add_to_folder_button>span,
159+ #bg_folder_remove_selected_button>span {
157160 display: none;
158161 }
159162
163+ #bg_group_select_count:not(:empty) {
164+ display: inline !important;
165+ }
166+
167+ .bg_folder_breadcrumb {
168+ gap: 5px;
169+ }
170+
171+ .bg_folder_breadcrumb .expander {
172+ min-width: 0;
173+ }
174+
175+ #bg_current_folder_name {
176+ min-width: 0;
177+ flex: 1 1 auto;
178+ }
179+
160180 #extensions_settings,
161181 #extensions_settings2 {
162182 width: 100% !important;
public/index.html+13 -0
@@ -5635,6 +5635,14 @@
56355635 <a href="#bg_chat_tab" data-i18n="Chat">Chat</a>
56365636 </li>
56375637 <div class="heading-controls">
5638+ <button id="bg_group_add_to_folder_button" class="menu_button menu_button_icon" style="display:none;" data-i18n="[title]Add selected backgrounds to folder" title="Add selected backgrounds to folder">
5639+ <i class="fa-solid fa-folder-tree"></i>
5640+ <span data-i18n="Add to Folder">Add to Folder</span>
5641+ </button>
5642+ <button id="bg_selection_mode_button" class="menu_button menu_button_icon" data-i18n="[title]Select backgrounds for bulk folder actions" title="Select backgrounds for bulk folder actions">
5643+ <i class="fa-solid fa-edit"></i>
5644+ <span id="bg_group_select_count" style="display:none;"></span>
5645+ </button>
56385646 <button id="bg_thumb_zoom_out" class="menu_button menu_button_icon" title="Make thumbnails smaller" data-i18n="[title]Make thumbnails smaller">
56395647 <i class="fa-solid fa-minus"></i>
56405648 </button>
@@ -5650,6 +5658,11 @@
56505658 <span data-i18n="Back">Back</span>
56515659 </button>
56525660 <span id="bg_current_folder_name" class="bg_current_folder_name"></span>
5661+ <span class="expander"></span>
5662+ <button id="bg_folder_remove_selected_button" class="menu_button menu_button_icon" style="display:none;" data-i18n="[title]Remove selected backgrounds from this folder" title="Remove selected backgrounds from this folder">
5663+ <i class="fa-solid fa-folder-minus"></i>
5664+ <span data-i18n="Remove from Folder">Remove from Folder</span>
5665+ </button>
56535666 </div>
56545667 <div id="bg_folder_grid" class="bg_list bg_folder_grid"></div>
56555668 <div id="bg_menu_content" class="bg_list"></div>
public/scripts/backgrounds.js+279 -32
@@ -20,6 +20,10 @@ let folderList = [];
2020let imageFolderMap = {};
2121/** @type {string|null} Currently active folder drill-in, or null for root */
2222let activeFolderId = null;
23+/** @type {Set<string>} Selected system backgrounds for group folder actions */
24+const selectedSystemBackgroundFiles = new Set();
25+/** @type {boolean} Whether click-to-select mode is active for system backgrounds */
26+let isBackgroundSelectionMode = false;
2327
2428// A single transparent PNG pixel used as a placeholder for errored backgrounds
2529const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
@@ -365,6 +369,11 @@ function removeBackgroundMetadata() {
365369function onSelectBackgroundClick(e) {
366370 const bgFile = $(this).attr('bgfile');
367371 const isCustom = $(this).attr('custom') === 'true';
372+ if (isBackgroundSelectionMode && !isCustom) {
373+ toggleBackgroundGroupSelection(bgFile);
374+ return;
375+ }
376+
368377 const backgroundCssUrl = getUrlParameter(this);
369378 const bypassGlobalLock = !isCustom && e.shiftKey;
370379
@@ -576,6 +585,7 @@ async function onDeleteBackgroundClick(e) {
576585 if (deletedBg) {
577586 const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
578587 if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
588+ selectedSystemBackgroundFiles.delete(deletedBg);
579589
580590 // Update folder map and clear folder thumbnail if it referenced this image
581591 if (imageFolderMap[deletedBg]) {
@@ -605,6 +615,7 @@ async function onDeleteBackgroundClick(e) {
605615
606616 highlightLockedBackground();
607617 highlightSelectedBackground();
618+ syncGroupSelectionUi();
608619 }
609620}
610621
@@ -652,7 +663,10 @@ function renderSystemBackgrounds(backgrounds) {
652663 const container = $('#bg_menu_content');
653664 container.empty();
654665
655666 if (sourceList.length === 0) return;{
667+ syncGroupSelectionUi();
668+ return;
669+ }
656670
657671 const sortedList = sortBackgrounds(sourceList.map(bg => bg.filename), false);
658672 const metadataByFilename = new Map(sourceList.map(bg => [bg.filename, bg]));
@@ -663,6 +677,7 @@ function renderSystemBackgrounds(backgrounds) {
663677 container.append(thumbnail);
664678 });
665679
680+ syncGroupSelectionUi();
666681 activateLazyLoader();
667682}
668683
@@ -700,6 +715,12 @@ export async function getBackgrounds() {
700715 const { images, config } = await response.json();
701716 Object.assign(THUMBNAIL_CONFIG, config);
702717 cachedSystemBackgrounds = images;
718+ const existingFiles = new Set(images.map(x => x.filename));
719+ for (const selectedFile of selectedSystemBackgroundFiles) {
720+ if (!existingFiles.has(selectedFile)) {
721+ selectedSystemBackgroundFiles.delete(selectedFile);
722+ }
723+ }
703724
704725 // Load folders first so getFilteredImages() works correctly in folder view
705726 await loadFolders();
@@ -859,6 +880,7 @@ function onFolderDrillIn(folderId) {
859880 const folder = folderList.find(f => f.id === folderId);
860881 if (!folder) return;
861882
883+ clearBackgroundGroupSelection();
862884 activeFolderId = folderId;
863885 $('#Backgrounds').addClass('in-folder-view');
864886
@@ -876,6 +898,7 @@ function onFolderDrillIn(folderId) {
876898 * Returns to the root folder overview.
877899 */
878900function onBackToFolders() {
901+ clearBackgroundGroupSelection();
879902 activeFolderId = null;
880903 $('#Backgrounds').removeClass('in-folder-view');
881904
@@ -890,6 +913,245 @@ function onBackToFolders() {
890913}
891914
892915/**
916+ * Refreshes click-to-select and group action UI state.
917+ */
918+function syncGroupSelectionUi() {
919+ const selectedCount = selectedSystemBackgroundFiles.size;
920+ const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL;
921+ const showAddButton = isGlobalTab && isBackgroundSelectionMode && selectedCount > 0;
922+ const showRemoveFromCurrentFolderButton = isGlobalTab && Boolean(activeFolderId) && isBackgroundSelectionMode && selectedCount > 0;
923+
924+ $('#Backgrounds').toggleClass('bg-selection-mode', isBackgroundSelectionMode);
925+ $('#bg_selection_mode_button').toggleClass('active', isBackgroundSelectionMode);
926+ $('#bg_group_select_count').text(selectedCount > 0 ? ` (${selectedCount})` : '').toggle(selectedCount > 0);
927+
928+ $('#bg_group_add_to_folder_button').toggle(showAddButton);
929+ $('#bg_folder_remove_selected_button').toggle(showRemoveFromCurrentFolderButton);
930+
931+ $('#bg_menu_content .bg_example').each(function () {
932+ const bgFile = String($(this).attr('bgfile') || '');
933+ $(this).toggleClass('folder-group-selected', selectedSystemBackgroundFiles.has(bgFile));
934+ });
935+}
936+
937+/**
938+ * Enables/disables click-to-select mode for system backgrounds.
939+ * @param {boolean} enabled
940+ */
941+function setBackgroundSelectionMode(enabled) {
942+ isBackgroundSelectionMode = enabled;
943+ if (!enabled) {
944+ selectedSystemBackgroundFiles.clear();
945+ }
946+ // Clear any open mobile menus
947+ $('#bg_menu_content .bg_example.mobile-menu-open').removeClass('mobile-menu-open');
948+ syncGroupSelectionUi();
949+}
950+
951+/**
952+ * Toggles selected state of a system background for group folder actions.
953+ * @param {string} bgFile
954+ */
955+function toggleBackgroundGroupSelection(bgFile) {
956+ if (!bgFile) return;
957+ if (selectedSystemBackgroundFiles.has(bgFile)) {
958+ selectedSystemBackgroundFiles.delete(bgFile);
959+ } else {
960+ selectedSystemBackgroundFiles.add(bgFile);
961+ }
962+ syncGroupSelectionUi();
963+}
964+
965+/**
966+ * Clears all selected system backgrounds for group folder actions.
967+ */
968+function clearBackgroundGroupSelection() {
969+ selectedSystemBackgroundFiles.clear();
970+ syncGroupSelectionUi();
971+}
972+
973+/**
974+ * Updates selection/folder action control visibility for the active tab.
975+ */
976+function updateGroupFolderControlsVisibility() {
977+ const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL;
978+ $('#bg_selection_mode_button').toggle(isGlobalTab);
979+
980+ if (!isGlobalTab && isBackgroundSelectionMode) {
981+ setBackgroundSelectionMode(false);
982+ return;
983+ }
984+ syncGroupSelectionUi();
985+}
986+
987+/**
988+ * Shows a folder selection popup and returns the selected folder id.
989+ * @param {string} headingText
990+ * @returns {Promise<string[]|null>} Array of selected folder IDs, or null if cancelled
991+ */
992+async function selectFoldersForGroupAction(headingText) {
993+ if (folderList.length === 0) {
994+ toastr.info(t`Create a folder first`);
995+ return null;
996+ }
997+
998+ const contentEl = document.createElement('div');
999+ const heading = document.createElement('h3');
1000+ heading.textContent = headingText;
1001+ contentEl.appendChild(heading);
1002+
1003+ for (const folder of folderList) {
1004+ const label = document.createElement('label');
1005+ label.className = 'checkbox_label flexGap5';
1006+ label.style.margin = '4px 0';
1007+
1008+ const checkbox = document.createElement('input');
1009+ checkbox.type = 'checkbox';
1010+ checkbox.dataset.folderId = folder.id;
1011+
1012+ const span = document.createElement('span');
1013+ span.textContent = folder.name;
1014+
1015+ label.appendChild(checkbox);
1016+ label.appendChild(span);
1017+ contentEl.appendChild(label);
1018+ }
1019+
1020+ const content = $(contentEl);
1021+ const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', {
1022+ okButton: t`Apply`,
1023+ cancelButton: t`Cancel`,
1024+ allowVerticalScrolling: true,
1025+ leftAlign: true,
1026+ });
1027+ if (!result) return null;
1028+
1029+ const selectedIds = [];
1030+ content.find('input[type="checkbox"]:checked').each(function () {
1031+ selectedIds.push($(this).data('folderId'));
1032+ });
1033+ return selectedIds.length > 0 ? selectedIds : null;
1034+}
1035+
1036+/**
1037+ * Sends a folder assign/unassign request and updates local imageFolderMap state.
1038+ * @param {string[]} bgFiles - Background filenames to update
1039+ * @param {string} folderId - Target folder ID
1040+ * @param {boolean} isRemove - Whether to remove (unassign) or add (assign)
1041+ */
1042+async function updateFolderAssignments(bgFiles, folderId, isRemove) {
1043+ const paths = bgFiles.map(getBackgroundRelativePath);
1044+ const endpoint = isRemove ? '/api/image-metadata/folders/unassign' : '/api/image-metadata/folders/assign';
1045+
1046+ const response = await fetch(endpoint, {
1047+ method: 'POST',
1048+ headers: getRequestHeaders(),
1049+ body: JSON.stringify({ id: folderId, paths }),
1050+ });
1051+
1052+ if (!response.ok) {
1053+ throw new Error(`Folder ${isRemove ? 'unassign' : 'assign'} failed: ${response.status}`);
1054+ }
1055+
1056+ for (const bgFile of bgFiles) {
1057+ const currentFolderIds = imageFolderMap[bgFile] || [];
1058+ if (isRemove) {
1059+ const nextFolderIds = currentFolderIds.filter(id => id !== folderId);
1060+ if (nextFolderIds.length > 0) {
1061+ imageFolderMap[bgFile] = nextFolderIds;
1062+ } else {
1063+ delete imageFolderMap[bgFile];
1064+ }
1065+ } else if (!currentFolderIds.includes(folderId)) {
1066+ imageFolderMap[bgFile] = [...currentFolderIds, folderId];
1067+ }
1068+ }
1069+}
1070+
1071+/**
1072+ * Adds selected system backgrounds to a chosen folder.
1073+ */
1074+async function onAddSelectedToFolder() {
1075+ if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) {
1076+ toastr.warning(t`Folder actions are only available in the Global tab`);
1077+ return;
1078+ }
1079+
1080+ const bgFiles = Array.from(selectedSystemBackgroundFiles);
1081+ if (bgFiles.length === 0) {
1082+ toastr.info(t`Select one or more backgrounds first`);
1083+ return;
1084+ }
1085+
1086+ const folderIds = await selectFoldersForGroupAction(t`Add selected backgrounds to folders`);
1087+ if (!folderIds) return;
1088+
1089+ try {
1090+ let totalAdded = 0;
1091+ for (const folderId of folderIds) {
1092+ const actionableBgFiles = bgFiles.filter(bgFile => {
1093+ const currentFolderIds = imageFolderMap[bgFile] || [];
1094+ return !currentFolderIds.includes(folderId);
1095+ });
1096+ if (actionableBgFiles.length > 0) {
1097+ await updateFolderAssignments(actionableBgFiles, folderId, false);
1098+ totalAdded += actionableBgFiles.length;
1099+ }
1100+ }
1101+
1102+ renderFolderGrid();
1103+
1104+ if (activeFolderId) {
1105+ renderSystemBackgrounds(getFilteredImages());
1106+ highlightSelectedBackground();
1107+ }
1108+
1109+ setBackgroundSelectionMode(false);
1110+ if (totalAdded > 0) {
1111+ toastr.success(t`Added backgrounds to ${folderIds.length} folder(s)`);
1112+ } else {
1113+ toastr.info(t`Selected backgrounds are already in the chosen folders`);
1114+ }
1115+ } catch (error) {
1116+ console.error('Error adding selected backgrounds to folder:', error);
1117+ toastr.error(t`Failed to update folder assignment`);
1118+ }
1119+}
1120+
1121+/**
1122+ * Removes selected system backgrounds from the currently drilled-in folder.
1123+ */
1124+async function onRemoveSelectedFromCurrentFolder() {
1125+ if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) {
1126+ toastr.warning(t`Folder actions are only available in the Global tab`);
1127+ return;
1128+ }
1129+
1130+ if (!activeFolderId) {
1131+ toastr.info(t`Open a folder first`);
1132+ return;
1133+ }
1134+
1135+ const bgFiles = Array.from(selectedSystemBackgroundFiles);
1136+ if (bgFiles.length === 0) {
1137+ toastr.info(t`Select one or more backgrounds first`);
1138+ return;
1139+ }
1140+
1141+ try {
1142+ await updateFolderAssignments(bgFiles, activeFolderId, true);
1143+ renderFolderGrid();
1144+ renderSystemBackgrounds(getFilteredImages());
1145+ highlightSelectedBackground();
1146+ setBackgroundSelectionMode(false);
1147+ toastr.success(t`Removed ${bgFiles.length} background(s) from folder`);
1148+ } catch (error) {
1149+ console.error('Error removing selected backgrounds from current folder:', error);
1150+ toastr.error(t`Failed to update folder assignment`);
1151+ }
1152+}
1153+
1154+/**
8931155 * Creates a new folder via API.
8941156 */
8951157async function onCreateFolder() {
@@ -1037,40 +1299,12 @@ async function onAssignToFolder(bgFile) {
10371299 if (!isChecked && wasChecked) toUnassign.push(fid);
10381300 });
10391301
1040- const relativePath = getBackgroundRelativePath(bgFile);
1041-
10421302 try {
10431303 for (const fid of toAssign) {
1044- const resp = await fetch('/api/image-metadata/folders/assign', {
1304+ await updateFolderAssignments([bgFile], fid, false);
1045- method: 'POST',
1046- headers: getRequestHeaders(),
1047- body: JSON.stringify({ id: fid, paths: [relativePath] }),
1048- });
1049- if (!resp.ok) throw new Error(`Assign to folder ${fid} failed: ${resp.status}`);
10501305 }
10511306 for (const fid of toUnassign) {
1052- const resp = await fetch('/api/image-metadata/folders/unassign', {
1307+ await updateFolderAssignments([bgFile], fid, true);
1053- method: 'POST',
1054- headers: getRequestHeaders(),
1055- body: JSON.stringify({ id: fid, paths: [relativePath] }),
1056- });
1057- if (!resp.ok) throw new Error(`Unassign from folder ${fid} failed: ${resp.status}`);
1058- }
1059-
1060- // Update local state
1061- const newFolderIds = folderList
1062- .filter(f => {
1063- const wasIn = currentFolderIds.includes(f.id);
1064- if (toAssign.includes(f.id)) return true;
1065- if (toUnassign.includes(f.id)) return false;
1066- return wasIn;
1067- })
1068- .map(f => f.id);
1069-
1070- if (newFolderIds.length > 0) {
1071- imageFolderMap[bgFile] = newFolderIds;
1072- } else {
1073- delete imageFolderMap[bgFile];
10741308 }
10751309
10761310 renderFolderGrid();
@@ -1449,7 +1683,11 @@ const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debou
14491683 * @returns {BG_SOURCES} Active background tab source
14501684 */
14511685export function getActiveBackgroundTab() {
14521686 returnconst tabs = $('#bg_tabs').tabs('option', 'active');
1687+ if (!tabs.length || !tabs.data('ui-tabs')) {
1688+ return BG_SOURCES.GLOBAL;
1689+ }
1690+ return tabs.tabs('option', 'active');
14531691}
14541692
14551693export function initBackgrounds() {
@@ -1511,6 +1749,9 @@ export function initBackgrounds() {
15111749 })
15121750 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
15131751 e.stopPropagation();
1752+ if (isBackgroundSelectionMode && $(this).closest('#bg_menu_content').length) {
1753+ return;
1754+ }
15141755 const action = $(this).data('action');
15151756
15161757 switch (action) {
@@ -1553,6 +1794,9 @@ export function initBackgrounds() {
15531794 applyThumbnailColumns(background_settings.thumbnailColumns + 1);
15541795 });
15551796 $('#auto_background').on('click', autoBackgroundCommand);
1797+ $('#bg_selection_mode_button').on('click', () => setBackgroundSelectionMode(!isBackgroundSelectionMode));
1798+ $('#bg_group_add_to_folder_button').on('click', onAddSelectedToFolder);
1799+ $('#bg_folder_remove_selected_button').on('click', onRemoveSelectedFromCurrentFolder);
15561800 $('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
15571801 $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
15581802 $('#bg-sort').on('change', function () {
@@ -1615,4 +1859,7 @@ export function initBackgrounds() {
16151859 });
16161860
16171861 $('#bg_tabs').tabs();
1862+ $('#bg_tabs').on('tabsactivate', () => updateGroupFolderControlsVisibility());
1863+ updateGroupFolderControlsVisibility();
1864+ syncGroupSelectionUi();
16181865}