Background Folders (#5187) * backend, frontend, bugfixes * Mobile button and sizing * lint * clear folder thumbnailFile on delete, rename thumbnailFile on rename * use filteredImages when changing sort option * Address all the review comments * Fix friendly title generation to handle empty strings * Move add folder button to the header * instead of search filtering the backgrounds in a folder and showing the folder if the results > 0, search the folder names. * Trade button places * Adjust button text * feat: restrict folder creation to the Global tab --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

54bba07420225e4c27f0b2fb5aaf1d13655703ed

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

Signed
6 files changed, +1079 -16Ignore whitespace
public/css/backgrounds.css+168 -1
@@ -107,7 +107,8 @@
107107 opacity: 1;
108108}
109109
110110.bg_example .mobile-only-menu-toggle {,
111+.bg_folder_tile .mobile-only-menu-toggle {
111112 display: none;
112113}
113114
@@ -376,3 +377,169 @@
376377#bg_custom_content:not(:empty)~#bg_chat_hint {
377378 display: none;
378379}
380+
381+.bg_folder_grid.bg_list {
382+ display: grid;
383+ gap: 5px;
384+ width: 100%;
385+ grid-template-columns: repeat(calc(var(--bg-thumb-columns, 5) + 2), 1fr);
386+ margin-bottom: 10px;
387+}
388+
389+.bg_folder_grid:empty {
390+ display: none;
391+}
392+
393+.bg_folder_tile {
394+ cursor: pointer;
395+ position: relative;
396+ overflow: hidden;
397+ border-radius: 8px;
398+ height: auto;
399+ aspect-ratio: 1 / 1;
400+ outline: 2px solid var(--SmartThemeBorderColor);
401+ outline-offset: -1px;
402+ box-shadow: 0 0 7px var(--black50a);
403+ display: flex;
404+ flex-direction: column;
405+ align-items: center;
406+ justify-content: center;
407+ background-color: var(--SmartThemeBlurTintColor);
408+ transition: filter var(--animation-duration) ease;
409+}
410+
411+.bg_folder_tile:hover {
412+ filter: brightness(1.1);
413+}
414+
415+.bg_folder_tile_cover {
416+ position: absolute;
417+ top: 0;
418+ left: 0;
419+ right: 0;
420+ bottom: 0;
421+ background-size: cover;
422+ background-position: center;
423+ border-radius: inherit;
424+}
425+
426+.bg_folder_tile_overlay {
427+ position: absolute;
428+ bottom: 0;
429+ left: 0;
430+ right: 0;
431+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
432+ color: var(--SmartThemeBodyColor);
433+ padding: 6px 8px 4px;
434+ display: flex;
435+ align-items: center;
436+ gap: 5px;
437+ font-size: 0.85em;
438+ font-weight: 600;
439+ pointer-events: none;
440+ border-radius: 0 0 8px 8px;
441+}
442+
443+.bg_folder_tile_overlay i {
444+ font-size: 0.9em;
445+ opacity: 0.8;
446+}
447+
448+.bg_folder_tile_name {
449+ white-space: nowrap;
450+ overflow: hidden;
451+ text-overflow: ellipsis;
452+}
453+
454+/* Folder tile action menu */
455+.bg_folder_tile .bg_folder_tile_menu {
456+ display: flex;
457+ position: absolute;
458+ top: 2px;
459+ right: 2px;
460+ background-color: rgba(0, 0, 0, 0.5);
461+ border-radius: 5px;
462+ padding: 3px;
463+ z-index: 3;
464+ backdrop-filter: blur(4px);
465+ border: 1px solid var(--SmartThemeBorderColor);
466+ align-items: center;
467+
468+ opacity: 0;
469+ visibility: hidden;
470+ transform: scale(0.9);
471+ transform-origin: center;
472+ transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
473+}
474+
475+.bg_folder_tile:hover .bg_folder_tile_menu,
476+.bg_folder_tile:focus-within .bg_folder_tile_menu {
477+ opacity: 1;
478+ visibility: visible;
479+ transform: scale(1);
480+}
481+
482+.bg_folder_tile .jg-button {
483+ display: flex;
484+ width: 24px;
485+ height: 24px;
486+ align-items: center;
487+ justify-content: center;
488+ color: white;
489+ padding: 5px;
490+ font-size: 1.1em;
491+ border-radius: 5px;
492+ transition: background-color var(--animation-duration) ease;
493+}
494+
495+.bg_folder_tile .jg-button:hover {
496+ background-color: rgba(255, 255, 255, 0.2);
497+}
498+
499+/* New Folder placeholder tile */
500+.bg_new_folder_tile {
501+ border: 2px dashed var(--SmartThemeBorderColor);
502+ outline: none;
503+ color: var(--SmartThemeBodyColor);
504+ opacity: 0.6;
505+ gap: 6px;
506+ font-size: 0.85em;
507+ font-weight: 600;
508+}
509+
510+.bg_new_folder_tile:hover {
511+ opacity: 1;
512+ transform: scale(1.03);
513+}
514+
515+/* Breadcrumb bar */
516+.bg_folder_breadcrumb {
517+ display: flex;
518+ align-items: center;
519+ gap: 8px;
520+ padding: 5px 0;
521+ margin-bottom: 5px;
522+}
523+
524+.bg_current_folder_name {
525+ font-weight: 600;
526+ font-size: 1em;
527+ color: var(--SmartThemeBodyColor);
528+ white-space: nowrap;
529+ overflow: hidden;
530+ text-overflow: ellipsis;
531+}
532+
533+#Backgrounds:not(.in-folder-view) .jg-set-cover {
534+ display: none !important;
535+}
536+
537+#Backgrounds.in-folder-view .jg-set-cover {
538+ display: flex !important;
539+}
540+
541+/* Hide folder actions on custom (chat-specific) backgrounds */
542+.bg_example[custom="true"] .jg-folder,
543+.bg_example[custom="true"] .jg-set-cover {
544+ display: none !important;
545+}
public/css/mobile-styles.css+52 -1
@@ -38,6 +38,56 @@
3838 grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
3939 }
4040
41+ .bg_folder_grid {
42+ grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
43+ }
44+
45+ .bg_folder_tile .bg_folder_tile_menu {
46+ opacity: 0;
47+ visibility: hidden;
48+ transform: scale(0.9);
49+ }
50+
51+ .bg_folder_tile:hover .bg_folder_tile_menu,
52+ .bg_folder_tile:focus-within .bg_folder_tile_menu {
53+ display: none;
54+ }
55+
56+ .bg_folder_tile.mobile-menu-open .bg_folder_tile_menu {
57+ display: flex;
58+ opacity: 1;
59+ visibility: visible;
60+ transform: scale(1);
61+ z-index: 4;
62+ }
63+
64+ .bg_folder_tile.mobile-menu-open .mobile-only-menu-toggle {
65+ opacity: 0;
66+ pointer-events: none;
67+ }
68+
69+ .bg_folder_tile .mobile-only-menu-toggle {
70+ display: flex;
71+ align-items: center;
72+ justify-content: center;
73+ position: absolute;
74+ top: 5px;
75+ right: 5px;
76+ width: 30px;
77+ height: 30px;
78+ background-color: rgba(0, 0, 0, 0.4);
79+ color: white;
80+ border-radius: 6px;
81+ z-index: 3;
82+ cursor: pointer;
83+ backdrop-filter: blur(2px);
84+ }
85+
86+ .bg_folder_tile .jg-button {
87+ width: 30px;
88+ height: 30px;
89+ }
90+
4191 .bg_list {
4292 width: unset;
4393 }
@@ -102,7 +152,8 @@
102152 }
103153
104154 #add_background_button_top>span,
105155 #auto_background>span {,
156+ #bg_add_folder_button>span {
106157 display: none;
107158 }
108159
public/index.html+36 -0
@@ -5597,6 +5597,10 @@
55975597 <i class="fa-solid fa-wand-magic"></i>
55985598 <span data-i18n="Auto-select">Auto-select</span>
55995599 </div>
5600+ <button id="bg_add_folder_button" class="menu_button menu_button_icon" data-i18n="[title]New Folder" title="New Folder">
5601+ <i class="fa-solid fa-folder-plus"></i>
5602+ <span data-i18n="New Folder">New Folder</span>
5603+ </button>
56005604 <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon" data-i18n="[title]Add a new background" title="Add a new background">
56015605 <i class="fa-solid fa-upload"></i>
56025606 <span data-i18n="Add Background">Add Background</span>
@@ -5630,6 +5634,14 @@
56305634 </div>
56315635 </ul>
56325636 <div id="bg_global_tab" class="bg_tab_panel">
5637+ <div id="bg_folder_breadcrumb" class="bg_folder_breadcrumb" style="display:none;">
5638+ <button id="bg_back_to_folders" class="menu_button menu_button_icon">
5639+ <i class="fa-solid fa-arrow-left"></i>
5640+ <span data-i18n="Back">Back</span>
5641+ </button>
5642+ <span id="bg_current_folder_name" class="bg_current_folder_name"></span>
5643+ </div>
5644+ <div id="bg_folder_grid" class="bg_list bg_folder_grid"></div>
56335645 <div id="bg_menu_content" class="bg_list"></div>
56345646 </div>
56355647 <div id="bg_chat_tab" class="bg_tab_panel">
@@ -6598,10 +6610,34 @@
65986610 <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to global backgrounds" title="Copy to global backgrounds"></div>
65996611 <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>
66006612 <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>
6613+ <div data-action="folder" class="jg-button jg-folder fa-solid fa-folder fa-fw pointer" data-i18n="[title]Add to folder" title="Add to folder"></div>
6614+ <div data-action="set-cover" class="jg-button jg-set-cover fa-solid fa-image fa-fw pointer" data-i18n="[title]Set as folder cover" title="Set as folder cover"></div>
66016615 </div>
66026616 <div class="BGSampleTitle"></div>
66036617 </div>
66046618 </div>
6619+ <div id="bg_folder_tile_template" class="template_element">
6620+ <div class="bg_folder_tile" data-folder-id="">
6621+ <div class="bg_folder_tile_cover"></div>
6622+ <div class="bg_folder_tile_overlay">
6623+ <i class="fa-solid fa-folder"></i>
6624+ <span class="bg_folder_tile_name"></span>
6625+ </div>
6626+ <div class="mobile-only-menu-toggle">
6627+ <i class="fa-solid fa-ellipsis-vertical"></i>
6628+ </div>
6629+ <div class="bg_folder_tile_menu jg-menu">
6630+ <div data-action="rename-folder" class="jg-button fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename folder" title="Rename folder"></div>
6631+ <div data-action="delete-folder" class="jg-button fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete folder" title="Delete folder"></div>
6632+ </div>
6633+ </div>
6634+ </div>
6635+ <div id="bg_new_folder_template" class="template_element">
6636+ <div class="bg_folder_tile bg_new_folder_tile">
6637+ <i class="fa-solid fa-folder-plus fa-2x"></i>
6638+ <span data-i18n="New Folder">New Folder</span>
6639+ </div>
6640+ </div>
66056641 <!-- templates for JS to reuse when needed -->
66066642 <div id="character_world_template" class="template_element">
66076643 <div class="character_world range-block flexFlowColumn flex-container">
public/scripts/backgrounds.js+494 -5
@@ -6,7 +6,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
66import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
77import { debounce_timeout } from './constants.js';
88import { t } from './i18n.js';
99import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
1010import { groups, selected_group } from './group-chats.js';
1111import { humanizedDateTime } from './RossAscends-mods.js';
1212import { deleteMediaFromServer } from './chats.js';
@@ -14,6 +14,13 @@ import { deleteMediaFromServer } from './chats.js';
1414const BG_METADATA_KEY = 'custom_background';
1515const LIST_METADATA_KEY = 'chat_backgrounds';
1616
17+/** @type {Array<{id: string, name: string, thumbnailFile: string}>} */
18+let folderList = [];
19+/** @type {Object.<string, string[]>} filename → folderIds */
20+let imageFolderMap = {};
21+/** @type {string|null} Currently active folder drill-in, or null for root */
22+let activeFolderId = null;
23+
1724// A single transparent PNG pixel used as a placeholder for errored backgrounds
1825const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
1926const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
@@ -170,7 +177,7 @@ function createThumbnailElement(imageData) {
170177
171178 const url = generateUrlParameter(bg, isCustom);
172179 const title = isCustom ? bg.split('/').pop() : bg;
173180 const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.'));
174181
175182 thumbnail.attr('title', title);
176183 thumbnail.attr('bgfile', bg);
@@ -278,6 +285,17 @@ export function getBackgroundPath(fileUrl) {
278285 return `backgrounds/${encodeURIComponent(fileUrl)}`;
279286}
280287
288+/**
289+ * Gets the raw server-side relative path for a background image (no URL encoding).
290+ * Used when communicating paths to the API (stored as plain strings in metadata).
291+ * @param {string} file File name of the background image
292+ * @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg"
293+ */
294+function getBackgroundRelativePath(file) {
295+ return `backgrounds/${file}`;
296+}
297+
298+
281299function highlightLockedBackground() {
282300 $('.bg_example.locked-background').removeClass('locked-background');
283301
@@ -553,6 +571,24 @@ async function onDeleteBackgroundClick(e) {
553571 }
554572 }
555573
574+ // Remove from local image list so it doesn't reappear on re-render
575+ const deletedBg = bgToDelete.attr('bgfile');
576+ if (deletedBg) {
577+ const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
578+ if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
579+
580+ // Update folder map and clear folder thumbnail if it referenced this image
581+ if (imageFolderMap[deletedBg]) {
582+ delete imageFolderMap[deletedBg];
583+ }
584+ for (const folder of folderList) {
585+ if (folder.thumbnailFile === deletedBg) {
586+ folder.thumbnailFile = '';
587+ }
588+ }
589+ renderFolderGrid();
590+ }
591+
556592 bgToDelete.remove();
557593
558594 if (url === chat_metadata[BG_METADATA_KEY]) {
@@ -664,9 +700,14 @@ export async function getBackgrounds() {
664700 const { images, config } = await response.json();
665701 Object.assign(THUMBNAIL_CONFIG, config);
666702 cachedSystemBackgrounds = images;
703+
704+ // Load folders first so getFilteredImages() works correctly in folder view
705+ await loadFolders();
706+
667707 await preloadImageMetadata();
668708
669- renderSystemBackgrounds(images);
709+ // Render only filtered images if inside a folder, otherwise all
710+ renderSystemBackgrounds(getFilteredImages());
670711 highlightSelectedBackground();
671712 }
672713}
@@ -696,6 +737,389 @@ async function preloadImageMetadata() {
696737 }
697738}
698739
740+/**
741+ * Loads folder data from the server (separate from image loading).
742+ */
743+async function loadFolders() {
744+ try {
745+ const response = await fetch('/api/backgrounds/folders', {
746+ method: 'POST',
747+ headers: getRequestHeaders(),
748+ body: JSON.stringify({}),
749+ });
750+ if (response.ok) {
751+ const data = await response.json();
752+ folderList = data.folders || [];
753+ imageFolderMap = data.imageFolderMap || {};
754+
755+ // Auto-assign thumbnail for folders that don't have one, then persist
756+ const allImages = cachedSystemBackgrounds.map(img => img.filename);
757+ /** @type {{id: string, thumbnailFile: string}[]} */
758+ const thumbnailUpdates = [];
759+ for (const folder of folderList) {
760+ if (!folder.thumbnailFile) {
761+ const firstImage = allImages.find(img => {
762+ const fids = imageFolderMap[img];
763+ return fids && fids.includes(folder.id);
764+ });
765+ if (firstImage) {
766+ folder.thumbnailFile = firstImage;
767+ thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage });
768+ }
769+ }
770+ }
771+ if (thumbnailUpdates.length > 0) {
772+ await fetch('/api/image-metadata/folders/set-thumbnails', {
773+ method: 'POST',
774+ headers: getRequestHeaders(),
775+ body: JSON.stringify({ updates: thumbnailUpdates }),
776+ }).catch(err => console.debug('Auto-thumbnail save failed:', err));
777+ }
778+
779+ renderFolderGrid();
780+ }
781+ } catch (error) {
782+ console.error('Error loading folders:', error);
783+ }
784+}
785+
786+/**
787+ * Renders the folder grid inside #bg_folder_grid.
788+ */
789+function renderFolderGrid() {
790+ const container = $('#bg_folder_grid');
791+ container.empty();
792+
793+ if (folderList.length === 0 && !activeFolderId) {
794+ return;
795+ }
796+
797+ for (const folder of folderList) {
798+ const tile = createFolderTileElement(folder);
799+ container.append(tile);
800+ }
801+}
802+
803+/**
804+ * Creates a single folder tile DOM element.
805+ * @param {{id: string, name: string, thumbnailFile: string}} folder
806+ * @returns {HTMLElement}
807+ */
808+function createFolderTileElement(folder) {
809+ const tile = $('#bg_folder_tile_template .bg_folder_tile').clone();
810+ tile.attr('data-folder-id', folder.id);
811+ tile.find('.bg_folder_tile_name').text(folder.name);
812+
813+ // Set cover image (async, update when resolved)
814+ getFolderCoverUrl(folder).then(coverUrl => {
815+ if (coverUrl) {
816+ tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`);
817+ }
818+ });
819+
820+ return tile.get(0);
821+}
822+
823+/**
824+ * Gets the cover image URL for a folder.
825+ * Uses thumbnailFile if set, otherwise falls back to the first image in the folder.
826+ * @param {{id: string, name: string, thumbnailFile: string}} folder
827+ * @returns {Promise<string|null>}
828+ */
829+async function getFolderCoverUrl(folder) {
830+ const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => {
831+ const fids = imageFolderMap[img.filename];
832+ return fids && fids.includes(folder.id);
833+ })?.filename;
834+ if (!file) return null;
835+
836+ if (isAnimatedBackgroundExtension(file) && !background_settings.animation) {
837+ return getThumbnailFromStorage(file, false);
838+ }
839+ return getThumbnailUrl('bg', file);
840+}
841+
842+/**
843+ * Gets images filtered by the active folder.
844+ * @returns {Array<{filename: string, isAnimated: boolean}>}
845+ */
846+function getFilteredImages() {
847+ if (!activeFolderId) return cachedSystemBackgrounds;
848+ return cachedSystemBackgrounds.filter(img => {
849+ const fids = imageFolderMap[img.filename];
850+ return fids && fids.includes(activeFolderId);
851+ });
852+}
853+
854+/**
855+ * Drills into a folder — hides folder grid, shows breadcrumb, filters images.
856+ * @param {string} folderId
857+ */
858+function onFolderDrillIn(folderId) {
859+ const folder = folderList.find(f => f.id === folderId);
860+ if (!folder) return;
861+
862+ activeFolderId = folderId;
863+ $('#Backgrounds').addClass('in-folder-view');
864+
865+ // Hide folder grid, show breadcrumb
866+ $('#bg_folder_grid').hide();
867+ $('#bg_folder_breadcrumb').show();
868+ $('#bg_current_folder_name').text(folder.name);
869+
870+ // Render only this folder's images
871+ renderSystemBackgrounds(getFilteredImages());
872+ highlightSelectedBackground();
873+}
874+
875+/**
876+ * Returns to the root folder overview.
877+ */
878+function onBackToFolders() {
879+ activeFolderId = null;
880+ $('#Backgrounds').removeClass('in-folder-view');
881+
882+ // Show folder grid, hide breadcrumb
883+ $('#bg_folder_grid').show();
884+ $('#bg_folder_breadcrumb').hide();
885+ $('#bg_current_folder_name').text('');
886+
887+ // Show all images
888+ renderSystemBackgrounds(getFilteredImages());
889+ highlightSelectedBackground();
890+}
891+
892+/**
893+ * Creates a new folder via API.
894+ */
895+async function onCreateFolder() {
896+ const currentTab = getActiveBackgroundTab();
897+ if (currentTab !== BG_SOURCES.GLOBAL) {
898+ toastr.warning(t`Folders can only be created in the Global tab`);
899+ return;
900+ }
901+
902+ const name = await Popup.show.input(t`Enter folder name:`);
903+ if (!name || !name.trim()) return;
904+
905+ try {
906+ const response = await fetch('/api/image-metadata/folders/create', {
907+ method: 'POST',
908+ headers: getRequestHeaders(),
909+ body: JSON.stringify({ name: name.trim() }),
910+ });
911+ if (response.ok) {
912+ const folder = await response.json();
913+ folderList.push(folder);
914+ renderFolderGrid();
915+ toastr.success(t`Folder created: ${folder.name}`);
916+ }
917+ } catch (error) {
918+ console.error('Error creating folder:', error);
919+ toastr.error(t`Failed to create folder`);
920+ }
921+}
922+
923+/**
924+ * Renames a folder via API.
925+ * @param {string} folderId
926+ */
927+async function onRenameFolder(folderId) {
928+ const folder = folderList.find(f => f.id === folderId);
929+ if (!folder) return;
930+
931+ const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name);
932+ if (!newName || !newName.trim() || newName.trim() === folder.name) return;
933+
934+ try {
935+ const response = await fetch('/api/image-metadata/folders/update', {
936+ method: 'POST',
937+ headers: getRequestHeaders(),
938+ body: JSON.stringify({ id: folderId, name: newName.trim() }),
939+ });
940+ if (response.ok) {
941+ folder.name = newName.trim();
942+ renderFolderGrid();
943+ toastr.success(t`Folder renamed`);
944+ }
945+ } catch (error) {
946+ console.error('Error renaming folder:', error);
947+ toastr.error(t`Failed to rename folder`);
948+ }
949+}
950+
951+/**
952+ * Deletes a folder via API.
953+ * @param {string} folderId
954+ */
955+async function onDeleteFolder(folderId) {
956+ const folder = folderList.find(f => f.id === folderId);
957+ if (!folder) return;
958+
959+ const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`);
960+ if (!confirm) return;
961+
962+ try {
963+ const response = await fetch('/api/image-metadata/folders/delete', {
964+ method: 'POST',
965+ headers: getRequestHeaders(),
966+ body: JSON.stringify({ id: folderId }),
967+ });
968+ if (response.ok) {
969+ folderList = folderList.filter(f => f.id !== folderId);
970+ // Clean imageFolderMap
971+ for (const fids of Object.values(imageFolderMap)) {
972+ const idx = fids.indexOf(folderId);
973+ if (idx !== -1) fids.splice(idx, 1);
974+ }
975+ // If we were inside this folder, go back
976+ if (activeFolderId === folderId) {
977+ onBackToFolders();
978+ }
979+ renderFolderGrid();
980+ toastr.success(t`Folder deleted`);
981+ }
982+ } catch (error) {
983+ console.error('Error deleting folder:', error);
984+ toastr.error(t`Failed to delete folder`);
985+ }
986+}
987+
988+/**
989+ * Shows a folder assignment popup for an image.
990+ * @param {string} bgFile - The background filename
991+ */
992+async function onAssignToFolder(bgFile) {
993+ if (folderList.length === 0) {
994+ toastr.info(t`Create a folder first`);
995+ return;
996+ }
997+
998+ const currentFolderIds = imageFolderMap[bgFile] || [];
999+
1000+ // Build checkbox inputs for Popup using DOM construction (avoids HTML injection)
1001+ const contentEl = document.createElement('div');
1002+ const heading = document.createElement('h3');
1003+ heading.textContent = t`Assign to folders`;
1004+ contentEl.appendChild(heading);
1005+
1006+ for (const f of folderList) {
1007+ const label = document.createElement('label');
1008+ label.className = 'checkbox_label flexGap5';
1009+ label.style.margin = '4px 0';
1010+
1011+ const checkbox = document.createElement('input');
1012+ checkbox.type = 'checkbox';
1013+ checkbox.dataset.folderId = f.id;
1014+ checkbox.checked = currentFolderIds.includes(f.id);
1015+
1016+ const span = document.createElement('span');
1017+ span.textContent = f.name;
1018+
1019+ label.appendChild(checkbox);
1020+ label.appendChild(span);
1021+ contentEl.appendChild(label);
1022+ }
1023+
1024+ const content = $(contentEl);
1025+
1026+ const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1027+ if (!result) return;
1028+
1029+ // Determine which folders were toggled on/off
1030+ const toAssign = [];
1031+ const toUnassign = [];
1032+ content.find('input[type="checkbox"]').each(function () {
1033+ const fid = $(this).data('folder-id');
1034+ const isChecked = $(this).prop('checked');
1035+ const wasChecked = currentFolderIds.includes(fid);
1036+ if (isChecked && !wasChecked) toAssign.push(fid);
1037+ if (!isChecked && wasChecked) toUnassign.push(fid);
1038+ });
1039+
1040+ const relativePath = getBackgroundRelativePath(bgFile);
1041+
1042+ try {
1043+ for (const fid of toAssign) {
1044+ const resp = await fetch('/api/image-metadata/folders/assign', {
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}`);
1050+ }
1051+ for (const fid of toUnassign) {
1052+ const resp = await fetch('/api/image-metadata/folders/unassign', {
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];
1074+ }
1075+
1076+ renderFolderGrid();
1077+
1078+ // Re-render filtered image list if currently inside a folder view
1079+ if (activeFolderId) {
1080+ renderSystemBackgrounds(getFilteredImages());
1081+ highlightSelectedBackground();
1082+ }
1083+
1084+ toastr.success(t`Folder assignment updated`);
1085+ } catch (error) {
1086+ console.error('Error assigning to folder:', error);
1087+ toastr.error(t`Failed to update folder assignment`);
1088+ }
1089+}
1090+
1091+/**
1092+ * Sets an image as the folder cover.
1093+ * @param {string} bgFile - The background filename
1094+ */
1095+async function onSetFolderCover(bgFile) {
1096+ if (!activeFolderId) return;
1097+
1098+ try {
1099+ const response = await fetch('/api/image-metadata/folders/update', {
1100+ method: 'POST',
1101+ headers: getRequestHeaders(),
1102+ body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }),
1103+ });
1104+ if (response.ok) {
1105+ const folder = folderList.find(f => f.id === activeFolderId);
1106+ if (folder) {
1107+ folder.thumbnailFile = bgFile;
1108+ // Update the DOM tile cover image
1109+ const coverUrl = await getFolderCoverUrl(folder);
1110+ if (coverUrl) {
1111+ $(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`)
1112+ .css('background-image', `url('${coverUrl}')`);
1113+ }
1114+ }
1115+ toastr.success(t`Folder cover updated`);
1116+ }
1117+ } catch (error) {
1118+ console.error('Error setting folder cover:', error);
1119+ toastr.error(t`Failed to set folder cover`);
1120+ }
1121+}
1122+
6991123function activateLazyLoader() {
7001124 // Disconnect previous observer to prevent memory leaks
7011125 if (lazyLoadObserver) {
@@ -1001,6 +1425,21 @@ function onBackgroundFilterInput() {
10011425 const hasMatch = title.toLowerCase().includes(filterValue);
10021426 $bg.toggle(hasMatch);
10031427 });
1428+
1429+ // Show/hide folder tiles based on whether folder name matches the filter
1430+ if (!activeFolderId) {
1431+ $('#bg_folder_grid .bg_folder_tile').each(function () {
1432+ const $tile = $(this);
1433+ const folderId = $tile.attr('data-folder-id');
1434+ if (!folderId || !filterValue) {
1435+ $tile.show();
1436+ return;
1437+ }
1438+ const folder = folderList.find(f => f.id === folderId);
1439+ const folderName = folder ? folder.name.toLowerCase() : '';
1440+ $tile.toggle(folderName.includes(filterValue));
1441+ });
1442+ }
10041443}
10051444
10061445const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
@@ -1017,6 +1456,41 @@ export function initBackgrounds() {
10171456 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
10181457 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
10191458
1459+ // Folder event handlers
1460+ $(document)
1461+ .on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) {
1462+ if ($(e.target).closest('.jg-button').length) return; // let button handler run
1463+ const folderId = $(this).attr('data-folder-id');
1464+ if (folderId) onFolderDrillIn(folderId);
1465+ })
1466+ .on('click', '#bg_add_folder_button', function () {
1467+ onCreateFolder();
1468+ })
1469+ .on('click', '#bg_back_to_folders', function () {
1470+ onBackToFolders();
1471+ })
1472+ .on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) {
1473+ e.stopPropagation();
1474+ const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1475+ if (folderId) onRenameFolder(folderId);
1476+ })
1477+ .on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) {
1478+ e.stopPropagation();
1479+ const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1480+ if (folderId) onDeleteFolder(folderId);
1481+ })
1482+ .on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) {
1483+ e.stopPropagation();
1484+ const $context = $(this).closest('.bg_folder_tile');
1485+ const wasOpen = $context.hasClass('mobile-menu-open');
1486+ // Close all other open menus before opening a new one.
1487+ $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
1488+ $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1489+ if (!wasOpen) {
1490+ $context.addClass('mobile-menu-open');
1491+ }
1492+ });
1493+
10201494 $(document)
10211495 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
10221496 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
@@ -1025,6 +1499,7 @@ export function initBackgrounds() {
10251499 const wasOpen = $context.hasClass('mobile-menu-open');
10261500 // Close all other open menus before opening a new one.
10271501 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1502+ $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
10281503 if (!wasOpen) {
10291504 $context.addClass('mobile-menu-open');
10301505 }
@@ -1054,6 +1529,20 @@ export function initBackgrounds() {
10541529 case 'copy':
10551530 onCopyToSystemBackgroundClick.call(this, e.originalEvent);
10561531 break;
1532+ case 'folder': {
1533+ const bgEl = $(this).closest('.bg_example');
1534+ if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1535+ const bgFile = bgEl.attr('bgfile');
1536+ if (bgFile) onAssignToFolder(bgFile);
1537+ break;
1538+ }
1539+ case 'set-cover': {
1540+ const bgEl = $(this).closest('.bg_example');
1541+ if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1542+ const bgFile = bgEl.attr('bgfile');
1543+ if (bgFile) onSetFolderCover(bgFile);
1544+ break;
1545+ }
10571546 }
10581547 });
10591548
@@ -1069,8 +1558,8 @@ export function initBackgrounds() {
10691558 $('#bg-sort').on('change', function () {
10701559 background_settings.sortOrder = String($(this).val());
10711560 saveSettingsDebounced();
10721561 // Re-render both galleries with new sort order (respecting active folder filter)
10731562 renderSystemBackgrounds(cachedSystemBackgroundsgetFilteredImages());
10741563 renderChatBackgrounds();
10751564 highlightSelectedBackground();
10761565 highlightLockedBackground();
src/endpoints/backgrounds.js+29 -2
@@ -5,7 +5,7 @@ import express from 'express';
55import sanitize from 'sanitize-filename';
66
77import { invalidateThumbnail } from './thumbnails.js';
88import { getOrGenerateMetadataBatchthumbnailDimensions, removeMetadatareadMetadataIndex, renameMetadata, thumbnailDimensionsremoveMetadata, getOrGenerateMetadataBatch } from './image-metadata.js';
99import { getImages } from '../util.js';
1010import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111
@@ -37,6 +37,34 @@ router.post('/all', async function (request, response) {
3737 }
3838});
3939
40+/**
41+ * POST /api/backgrounds/folders
42+ * Returns folders and per-image folderIds from the metadata index.
43+ * Loaded separately from /all to avoid blocking image rendering.
44+ */
45+router.post('/folders', async function (request, response) {
46+ try {
47+ const index = await readMetadataIndex(request.user.directories.root);
48+ const folders = index.folders || [];
49+
50+ // Build a slim map of image → folderIds for the frontend
51+ /** @type {Object.<string, string[]>} */
52+ const imageFolderMap = {};
53+ for (const [relativePath, meta] of Object.entries(index.images)) {
54+ if (Array.isArray(meta.folderIds) && meta.folderIds.length > 0) {
55+ // Strip the directory prefix to get just the filename
56+ const filename = relativePath.split('/').pop() || relativePath;
57+ imageFolderMap[filename] = meta.folderIds;
58+ }
59+ }
60+
61+ response.json({ folders, imageFolderMap });
62+ } catch (error) {
63+ console.error('[Backgrounds] Folders endpoint error:', error);
64+ response.status(500).json({ error: 'Internal server error.' });
65+ }
66+});
67+
4068router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
4169 try {
4270 if (!request.body) return response.sendStatus(400);
@@ -110,7 +138,6 @@ router.post('/upload', async function (request, response) {
110138
111139 const img_path = path.join(request.file.destination, request.file.filename);
112140 const filename = sanitize(request.file.originalname);
113-
114141 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
115142 fs.unlinkSync(img_path);
116143 invalidateThumbnail(request.user.directories, 'bg', filename);
src/endpoints/image-metadata.js+300 -7
@@ -10,19 +10,19 @@ import { imageSize } from 'image-size';
1010import writeFileAtomic from 'write-file-atomic';
1111import express from 'express';
1212import { Jimp } from '../jimp.js';
1313import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js';
1414
1515export const METADATA_FILE = 'image-metadata.json';
1616
1717/**
1818 * @typedef {Object} ImageMetadata
1919 * @property {string} [hash] - SHA-256 hash of the image file.
2020 * @property {number} [aspectRatio] - Aspect ratio (width / height) of the image.
2121 * @property {boolean} [isAnimated] - Whether the image is animated.
2222 * @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB').
2323 * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
2424 * @property {number} [addedTimestamp] - Timestamp when the image was added.
2525 * @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation.
2626 * @property {number} [mtime] - File modification time for cache invalidation (internal use).
2727 */
2828
@@ -255,6 +255,17 @@ export async function removeMetadata(userDataRoot, relativePath) {
255255 const index = await readMetadataIndex(userDataRoot);
256256 if (index.images[posixPath]) {
257257 delete index.images[posixPath];
258+
259+ // Clear any folder thumbnailFile references that point to the deleted file
260+ const deletedFileName = path.posix.basename(posixPath);
261+ if (Array.isArray(index.folders)) {
262+ for (const folder of index.folders) {
263+ if (folder.thumbnailFile === deletedFileName) {
264+ folder.thumbnailFile = '';
265+ }
266+ }
267+ }
268+
258269 await writeMetadataIndex(userDataRoot, index);
259270 }
260271}
@@ -278,6 +289,18 @@ export async function renameMetadata(userDataRoot, oldRelativePath, newRelativeP
278289
279290 delete index.images[posixOldPath];
280291 index.images[posixNewPath] = data;
292+
293+ // Update any folder thumbnailFile references that point to the old filename
294+ const oldFileName = path.posix.basename(posixOldPath);
295+ const newFileName = path.posix.basename(posixNewPath);
296+ if (oldFileName !== newFileName && Array.isArray(index.folders)) {
297+ for (const folder of index.folders) {
298+ if (folder.thumbnailFile === oldFileName) {
299+ folder.thumbnailFile = newFileName;
300+ }
301+ }
302+ }
303+
281304 await writeMetadataIndex(userDataRoot, index);
282305
283306 return data;
@@ -319,10 +342,280 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
319342 return orphanedPaths;
320343}
321344
345+/**
346+ * Creates a new virtual folder.
347+ * @param {string} userDataRoot
348+ * @param {string} name
349+ * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
350+ */
351+export async function createFolder(userDataRoot, name) {
352+ const index = await readMetadataIndex(userDataRoot);
353+ const id = uuidv4();
354+ const folder = { id, name, thumbnailFile: '' };
355+ index.folders.push(folder);
356+ await writeMetadataIndex(userDataRoot, index);
357+ return folder;
358+}
359+
360+/**
361+ * Sets thumbnail files for multiple folders in a single atomic read-modify-write.
362+ * Folders not found in the index are silently skipped.
363+ * @param {string} userDataRoot
364+ * @param {{id: string, thumbnailFile: string}[]} updates
365+ * @returns {Promise<void>}
366+ */
367+export async function setFolderThumbnailsBatch(userDataRoot, updates) {
368+ const index = await readMetadataIndex(userDataRoot);
369+ for (const { id, thumbnailFile } of updates) {
370+ const folder = index.folders.find(f => f.id === id);
371+ if (folder) {
372+ folder.thumbnailFile = thumbnailFile;
373+ }
374+ }
375+ await writeMetadataIndex(userDataRoot, index);
376+}
377+
378+/**
379+ * Renames or updates a virtual folder.
380+ * @param {string} userDataRoot
381+ * @param {string} folderId
382+ * @param {{name?: string, thumbnailFile?: string}} updates
383+ * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
384+ */
385+export async function updateFolder(userDataRoot, folderId, updates) {
386+ const index = await readMetadataIndex(userDataRoot);
387+ const folder = index.folders.find(f => f.id === folderId);
388+ if (!folder) throw new Error(`Folder '${folderId}' not found.`);
389+ if (updates.name !== undefined) folder.name = updates.name;
390+ if (updates.thumbnailFile !== undefined) folder.thumbnailFile = updates.thumbnailFile;
391+ await writeMetadataIndex(userDataRoot, index);
392+ return folder;
393+}
394+
395+/**
396+ * Deletes a virtual folder and removes its ID from all images.
397+ * @param {string} userDataRoot
398+ * @param {string} folderId
399+ * @returns {Promise<void>}
400+ */
401+export async function deleteFolder(userDataRoot, folderId) {
402+ const index = await readMetadataIndex(userDataRoot);
403+ const idx = index.folders.findIndex(f => f.id === folderId);
404+ if (idx === -1) throw new Error(`Folder '${folderId}' not found.`);
405+ index.folders.splice(idx, 1);
406+ // Remove folderId from all images
407+ for (const meta of Object.values(index.images)) {
408+ if (Array.isArray(meta.folderIds)) {
409+ const fi = meta.folderIds.indexOf(folderId);
410+ if (fi !== -1) meta.folderIds.splice(fi, 1);
411+ }
412+ }
413+ await writeMetadataIndex(userDataRoot, index);
414+}
415+
416+/**
417+ * Assigns images to a folder.
418+ * @param {string} userDataRoot
419+ * @param {string} folderId
420+ * @param {string[]} relativePaths
421+ * @returns {Promise<void>}
422+ */
423+export async function assignImagesToFolder(userDataRoot, folderId, relativePaths) {
424+ const index = await readMetadataIndex(userDataRoot);
425+ if (!index.folders.some(f => f.id === folderId)) {
426+ throw new Error(`Folder '${folderId}' not found.`);
427+ }
428+ for (const rp of relativePaths) {
429+ const posixPath = rp.replaceAll(path.sep, path.posix.sep);
430+
431+ // Validate: must be a backgrounds/ path, and no path-traversal segments
432+ const normalized = path.posix.normalize(posixPath);
433+ if (!normalized.startsWith('backgrounds/') || normalized.split('/').some(seg => seg === '..')) {
434+ throw new Error(`Invalid background path: '${posixPath}'`);
435+ }
436+
437+ // Validate: skip silently on missing files
438+ const absPath = path.join(userDataRoot, normalized);
439+ try {
440+ await fs.access(absPath);
441+ } catch {
442+ console.warn(`[ImageMetadata] Skipping missing background file: '${posixPath}'`);
443+ continue;
444+ }
445+
446+ let meta = index.images[normalized];
447+ if (!meta) {
448+ // Create a stub entry so folderIds can be stored even before full metadata generation
449+ meta = { folderIds: [] };
450+ index.images[normalized] = meta;
451+ }
452+ if (!Array.isArray(meta.folderIds)) meta.folderIds = [];
453+ if (!meta.folderIds.includes(folderId)) {
454+ meta.folderIds.push(folderId);
455+ }
456+ }
457+ await writeMetadataIndex(userDataRoot, index);
458+}
459+
460+/**
461+ * Unassigns images from a folder.
462+ * @param {string} userDataRoot
463+ * @param {string} folderId
464+ * @param {string[]} relativePaths
465+ * @returns {Promise<void>}
466+ */
467+export async function unassignImagesFromFolder(userDataRoot, folderId, relativePaths) {
468+ const index = await readMetadataIndex(userDataRoot);
469+ for (const rp of relativePaths) {
470+ const posixPath = rp.replaceAll(path.sep, path.posix.sep);
471+ const meta = index.images[posixPath];
472+ if (!meta || !Array.isArray(meta.folderIds)) continue;
473+ const fi = meta.folderIds.indexOf(folderId);
474+ if (fi !== -1) meta.folderIds.splice(fi, 1);
475+ }
476+ await writeMetadataIndex(userDataRoot, index);
477+}
322478
323479export const router = express.Router();
324480
325481/**
482+ * POST /api/image-metadata/folders/get
483+ * List all virtual folders.
484+ */
485+router.post('/folders/get', async function (request, response) {
486+ try {
487+ const index = await readMetadataIndex(request.user.directories.root);
488+ return response.json(index.folders || []);
489+ } catch (error) {
490+ console.error('[ImageMetadata] Folders list error:', error);
491+ return response.status(500).json({ error: 'Internal server error.' });
492+ }
493+});
494+
495+/**
496+ * POST /api/image-metadata/folders/create
497+ * Create a new folder. Body: { name: string }
498+ */
499+router.post('/folders/create', async function (request, response) {
500+ try {
501+ const { name } = request.body;
502+ if (!name || typeof name !== 'string') {
503+ return response.status(400).json({ error: '"name" is required.' });
504+ }
505+ const folder = await createFolder(request.user.directories.root, name.trim());
506+ return response.json(folder);
507+ } catch (error) {
508+ console.error('[ImageMetadata] Folder create error:', error);
509+ return response.status(500).json({ error: 'Internal server error.' });
510+ }
511+});
512+
513+/**
514+ * POST /api/image-metadata/folders/set-thumbnails
515+ * Batch-set thumbnail files for multiple folders in one write. Body: { updates: [{id, thumbnailFile}] }
516+ */
517+router.post('/folders/set-thumbnails', async function (request, response) {
518+ try {
519+ const { updates } = request.body;
520+ if (!Array.isArray(updates) || updates.some(u => !u.id || typeof u.thumbnailFile !== 'string')) {
521+ return response.status(400).json({ error: '"updates" must be an array of {id, thumbnailFile}.' });
522+ }
523+ await setFolderThumbnailsBatch(request.user.directories.root, updates);
524+ return response.json({ ok: true });
525+ } catch (error) {
526+ console.error('[ImageMetadata] Folder set-thumbnails error:', error);
527+ return response.status(500).json({ error: 'Internal server error.' });
528+ }
529+});
530+
531+/**
532+ * POST /api/image-metadata/folders/update
533+ * Update a folder. Body: { id: string, name?: string, thumbnailFile?: string }
534+ */
535+router.post('/folders/update', async function (request, response) {
536+ try {
537+ const { id, ...updates } = request.body;
538+ if (!id || typeof id !== 'string') {
539+ return response.status(400).json({ error: '"id" is required.' });
540+ }
541+ const folder = await updateFolder(request.user.directories.root, id, updates);
542+ return response.json(folder);
543+ } catch (error) {
544+ if (error.message.includes('not found')) {
545+ return response.status(404).json({ error: error.message });
546+ }
547+ console.error('[ImageMetadata] Folder update error:', error);
548+ return response.status(500).json({ error: 'Internal server error.' });
549+ }
550+});
551+
552+/**
553+ * POST /api/image-metadata/folders/delete
554+ * Delete a folder and unassign all images. Body: { id: string }
555+ */
556+router.post('/folders/delete', async function (request, response) {
557+ try {
558+ const { id } = request.body;
559+ if (!id || typeof id !== 'string') {
560+ return response.status(400).json({ error: '"id" is required.' });
561+ }
562+ await deleteFolder(request.user.directories.root, id);
563+ return response.json({ ok: true });
564+ } catch (error) {
565+ if (error.message.includes('not found')) {
566+ return response.status(404).json({ error: error.message });
567+ }
568+ console.error('[ImageMetadata] Folder delete error:', error);
569+ return response.status(500).json({ error: 'Internal server error.' });
570+ }
571+});
572+
573+/**
574+ * POST /api/image-metadata/folders/assign
575+ * Assign images to a folder. Body: { id: string, paths: string[] }
576+ */
577+router.post('/folders/assign', async function (request, response) {
578+ try {
579+ const { id, paths } = request.body;
580+ if (!id || typeof id !== 'string') {
581+ return response.status(400).json({ error: '"id" is required.' });
582+ }
583+ if (!Array.isArray(paths)) {
584+ return response.status(400).json({ error: '"paths" array is required.' });
585+ }
586+ await assignImagesToFolder(request.user.directories.root, id, paths);
587+ return response.json({ ok: true });
588+ } catch (error) {
589+ if (error.message.includes('not found')) {
590+ return response.status(404).json({ error: error.message });
591+ }
592+ console.error('[ImageMetadata] Folder assign error:', error);
593+ return response.status(500).json({ error: 'Internal server error.' });
594+ }
595+});
596+
597+/**
598+ * POST /api/image-metadata/folders/unassign
599+ * Unassign images from a folder. Body: { id: string, paths: string[] }
600+ */
601+router.post('/folders/unassign', async function (request, response) {
602+ try {
603+ const { id, paths } = request.body;
604+ if (!id || typeof id !== 'string') {
605+ return response.status(400).json({ error: '"id" is required.' });
606+ }
607+ if (!Array.isArray(paths)) {
608+ return response.status(400).json({ error: '"paths" array is required.' });
609+ }
610+ await unassignImagesFromFolder(request.user.directories.root, id, paths);
611+ return response.json({ ok: true });
612+ } catch (error) {
613+ console.error('[ImageMetadata] Folder unassign error:', error);
614+ return response.status(500).json({ error: 'Internal server error.' });
615+ }
616+});
617+
618+/**
326619 * POST /api/image-metadata
327620 * Get metadata for image(s) by path.
328621 */