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>
This commit is contained in:
Lucas Scala
2026-02-24 13:27:03 -06:00
committed by GitHub
parent 537f0559ab
commit 54bba07420
6 changed files with 1079 additions and 16 deletions
+168 -1
View File
@@ -107,7 +107,8 @@
opacity: 1;
}
.bg_example .mobile-only-menu-toggle {
.bg_example .mobile-only-menu-toggle,
.bg_folder_tile .mobile-only-menu-toggle {
display: none;
}
@@ -376,3 +377,169 @@
#bg_custom_content:not(:empty)~#bg_chat_hint {
display: none;
}
.bg_folder_grid.bg_list {
display: grid;
gap: 5px;
width: 100%;
grid-template-columns: repeat(calc(var(--bg-thumb-columns, 5) + 2), 1fr);
margin-bottom: 10px;
}
.bg_folder_grid:empty {
display: none;
}
.bg_folder_tile {
cursor: pointer;
position: relative;
overflow: hidden;
border-radius: 8px;
height: auto;
aspect-ratio: 1 / 1;
outline: 2px solid var(--SmartThemeBorderColor);
outline-offset: -1px;
box-shadow: 0 0 7px var(--black50a);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--SmartThemeBlurTintColor);
transition: filter var(--animation-duration) ease;
}
.bg_folder_tile:hover {
filter: brightness(1.1);
}
.bg_folder_tile_cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
border-radius: inherit;
}
.bg_folder_tile_overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
color: var(--SmartThemeBodyColor);
padding: 6px 8px 4px;
display: flex;
align-items: center;
gap: 5px;
font-size: 0.85em;
font-weight: 600;
pointer-events: none;
border-radius: 0 0 8px 8px;
}
.bg_folder_tile_overlay i {
font-size: 0.9em;
opacity: 0.8;
}
.bg_folder_tile_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Folder tile action menu */
.bg_folder_tile .bg_folder_tile_menu {
display: flex;
position: absolute;
top: 2px;
right: 2px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 5px;
padding: 3px;
z-index: 3;
backdrop-filter: blur(4px);
border: 1px solid var(--SmartThemeBorderColor);
align-items: center;
opacity: 0;
visibility: hidden;
transform: scale(0.9);
transform-origin: center;
transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
}
.bg_folder_tile:hover .bg_folder_tile_menu,
.bg_folder_tile:focus-within .bg_folder_tile_menu {
opacity: 1;
visibility: visible;
transform: scale(1);
}
.bg_folder_tile .jg-button {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
color: white;
padding: 5px;
font-size: 1.1em;
border-radius: 5px;
transition: background-color var(--animation-duration) ease;
}
.bg_folder_tile .jg-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* New Folder placeholder tile */
.bg_new_folder_tile {
border: 2px dashed var(--SmartThemeBorderColor);
outline: none;
color: var(--SmartThemeBodyColor);
opacity: 0.6;
gap: 6px;
font-size: 0.85em;
font-weight: 600;
}
.bg_new_folder_tile:hover {
opacity: 1;
transform: scale(1.03);
}
/* Breadcrumb bar */
.bg_folder_breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
margin-bottom: 5px;
}
.bg_current_folder_name {
font-weight: 600;
font-size: 1em;
color: var(--SmartThemeBodyColor);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#Backgrounds:not(.in-folder-view) .jg-set-cover {
display: none !important;
}
#Backgrounds.in-folder-view .jg-set-cover {
display: flex !important;
}
/* Hide folder actions on custom (chat-specific) backgrounds */
.bg_example[custom="true"] .jg-folder,
.bg_example[custom="true"] .jg-set-cover {
display: none !important;
}
+52 -1
View File
@@ -38,6 +38,56 @@
grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
}
.bg_folder_grid {
grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
}
.bg_folder_tile .bg_folder_tile_menu {
opacity: 0;
visibility: hidden;
transform: scale(0.9);
}
.bg_folder_tile:hover .bg_folder_tile_menu,
.bg_folder_tile:focus-within .bg_folder_tile_menu {
display: none;
}
.bg_folder_tile.mobile-menu-open .bg_folder_tile_menu {
display: flex;
opacity: 1;
visibility: visible;
transform: scale(1);
z-index: 4;
}
.bg_folder_tile.mobile-menu-open .mobile-only-menu-toggle {
opacity: 0;
pointer-events: none;
}
.bg_folder_tile .mobile-only-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 5px;
right: 5px;
width: 30px;
height: 30px;
background-color: rgba(0, 0, 0, 0.4);
color: white;
border-radius: 6px;
z-index: 3;
cursor: pointer;
backdrop-filter: blur(2px);
}
.bg_folder_tile .jg-button {
width: 30px;
height: 30px;
}
.bg_list {
width: unset;
}
@@ -102,7 +152,8 @@
}
#add_background_button_top>span,
#auto_background>span {
#auto_background>span,
#bg_add_folder_button>span {
display: none;
}
+36
View File
@@ -5597,6 +5597,10 @@
<i class="fa-solid fa-wand-magic"></i>
<span data-i18n="Auto-select">Auto-select</span>
</div>
<button id="bg_add_folder_button" class="menu_button menu_button_icon" data-i18n="[title]New Folder" title="New Folder">
<i class="fa-solid fa-folder-plus"></i>
<span data-i18n="New Folder">New Folder</span>
</button>
<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">
<i class="fa-solid fa-upload"></i>
<span data-i18n="Add Background">Add Background</span>
@@ -5630,6 +5634,14 @@
</div>
</ul>
<div id="bg_global_tab" class="bg_tab_panel">
<div id="bg_folder_breadcrumb" class="bg_folder_breadcrumb" style="display:none;">
<button id="bg_back_to_folders" class="menu_button menu_button_icon">
<i class="fa-solid fa-arrow-left"></i>
<span data-i18n="Back">Back</span>
</button>
<span id="bg_current_folder_name" class="bg_current_folder_name"></span>
</div>
<div id="bg_folder_grid" class="bg_list bg_folder_grid"></div>
<div id="bg_menu_content" class="bg_list"></div>
</div>
<div id="bg_chat_tab" class="bg_tab_panel">
@@ -6598,10 +6610,34 @@
<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>
<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>
<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>
<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>
<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>
</div>
<div class="BGSampleTitle"></div>
</div>
</div>
<div id="bg_folder_tile_template" class="template_element">
<div class="bg_folder_tile" data-folder-id="">
<div class="bg_folder_tile_cover"></div>
<div class="bg_folder_tile_overlay">
<i class="fa-solid fa-folder"></i>
<span class="bg_folder_tile_name"></span>
</div>
<div class="mobile-only-menu-toggle">
<i class="fa-solid fa-ellipsis-vertical"></i>
</div>
<div class="bg_folder_tile_menu jg-menu">
<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>
<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>
</div>
</div>
</div>
<div id="bg_new_folder_template" class="template_element">
<div class="bg_folder_tile bg_new_folder_tile">
<i class="fa-solid fa-folder-plus fa-2x"></i>
<span data-i18n="New Folder">New Folder</span>
</div>
</div>
<!-- templates for JS to reuse when needed -->
<div id="character_world_template" class="template_element">
<div class="character_world range-block flexFlowColumn flex-container">
+494 -5
View File
@@ -6,7 +6,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
import { debounce_timeout } from './constants.js';
import { t } from './i18n.js';
import { Popup } from './popup.js';
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
import { groups, selected_group } from './group-chats.js';
import { humanizedDateTime } from './RossAscends-mods.js';
import { deleteMediaFromServer } from './chats.js';
@@ -14,6 +14,13 @@ import { deleteMediaFromServer } from './chats.js';
const BG_METADATA_KEY = 'custom_background';
const LIST_METADATA_KEY = 'chat_backgrounds';
/** @type {Array<{id: string, name: string, thumbnailFile: string}>} */
let folderList = [];
/** @type {Object.<string, string[]>} filename → folderIds */
let imageFolderMap = {};
/** @type {string|null} Currently active folder drill-in, or null for root */
let activeFolderId = null;
// A single transparent PNG pixel used as a placeholder for errored backgrounds
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
@@ -170,7 +177,7 @@ function createThumbnailElement(imageData) {
const url = generateUrlParameter(bg, isCustom);
const title = isCustom ? bg.split('/').pop() : bg;
const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.'));
thumbnail.attr('title', title);
thumbnail.attr('bgfile', bg);
@@ -278,6 +285,17 @@ export function getBackgroundPath(fileUrl) {
return `backgrounds/${encodeURIComponent(fileUrl)}`;
}
/**
* Gets the raw server-side relative path for a background image (no URL encoding).
* Used when communicating paths to the API (stored as plain strings in metadata).
* @param {string} file File name of the background image
* @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg"
*/
function getBackgroundRelativePath(file) {
return `backgrounds/${file}`;
}
function highlightLockedBackground() {
$('.bg_example.locked-background').removeClass('locked-background');
@@ -553,6 +571,24 @@ async function onDeleteBackgroundClick(e) {
}
}
// Remove from local image list so it doesn't reappear on re-render
const deletedBg = bgToDelete.attr('bgfile');
if (deletedBg) {
const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
// Update folder map and clear folder thumbnail if it referenced this image
if (imageFolderMap[deletedBg]) {
delete imageFolderMap[deletedBg];
}
for (const folder of folderList) {
if (folder.thumbnailFile === deletedBg) {
folder.thumbnailFile = '';
}
}
renderFolderGrid();
}
bgToDelete.remove();
if (url === chat_metadata[BG_METADATA_KEY]) {
@@ -664,9 +700,14 @@ export async function getBackgrounds() {
const { images, config } = await response.json();
Object.assign(THUMBNAIL_CONFIG, config);
cachedSystemBackgrounds = images;
// Load folders first so getFilteredImages() works correctly in folder view
await loadFolders();
await preloadImageMetadata();
renderSystemBackgrounds(images);
// Render only filtered images if inside a folder, otherwise all
renderSystemBackgrounds(getFilteredImages());
highlightSelectedBackground();
}
}
@@ -696,6 +737,389 @@ async function preloadImageMetadata() {
}
}
/**
* Loads folder data from the server (separate from image loading).
*/
async function loadFolders() {
try {
const response = await fetch('/api/backgrounds/folders', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const data = await response.json();
folderList = data.folders || [];
imageFolderMap = data.imageFolderMap || {};
// Auto-assign thumbnail for folders that don't have one, then persist
const allImages = cachedSystemBackgrounds.map(img => img.filename);
/** @type {{id: string, thumbnailFile: string}[]} */
const thumbnailUpdates = [];
for (const folder of folderList) {
if (!folder.thumbnailFile) {
const firstImage = allImages.find(img => {
const fids = imageFolderMap[img];
return fids && fids.includes(folder.id);
});
if (firstImage) {
folder.thumbnailFile = firstImage;
thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage });
}
}
}
if (thumbnailUpdates.length > 0) {
await fetch('/api/image-metadata/folders/set-thumbnails', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ updates: thumbnailUpdates }),
}).catch(err => console.debug('Auto-thumbnail save failed:', err));
}
renderFolderGrid();
}
} catch (error) {
console.error('Error loading folders:', error);
}
}
/**
* Renders the folder grid inside #bg_folder_grid.
*/
function renderFolderGrid() {
const container = $('#bg_folder_grid');
container.empty();
if (folderList.length === 0 && !activeFolderId) {
return;
}
for (const folder of folderList) {
const tile = createFolderTileElement(folder);
container.append(tile);
}
}
/**
* Creates a single folder tile DOM element.
* @param {{id: string, name: string, thumbnailFile: string}} folder
* @returns {HTMLElement}
*/
function createFolderTileElement(folder) {
const tile = $('#bg_folder_tile_template .bg_folder_tile').clone();
tile.attr('data-folder-id', folder.id);
tile.find('.bg_folder_tile_name').text(folder.name);
// Set cover image (async, update when resolved)
getFolderCoverUrl(folder).then(coverUrl => {
if (coverUrl) {
tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`);
}
});
return tile.get(0);
}
/**
* Gets the cover image URL for a folder.
* Uses thumbnailFile if set, otherwise falls back to the first image in the folder.
* @param {{id: string, name: string, thumbnailFile: string}} folder
* @returns {Promise<string|null>}
*/
async function getFolderCoverUrl(folder) {
const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => {
const fids = imageFolderMap[img.filename];
return fids && fids.includes(folder.id);
})?.filename;
if (!file) return null;
if (isAnimatedBackgroundExtension(file) && !background_settings.animation) {
return getThumbnailFromStorage(file, false);
}
return getThumbnailUrl('bg', file);
}
/**
* Gets images filtered by the active folder.
* @returns {Array<{filename: string, isAnimated: boolean}>}
*/
function getFilteredImages() {
if (!activeFolderId) return cachedSystemBackgrounds;
return cachedSystemBackgrounds.filter(img => {
const fids = imageFolderMap[img.filename];
return fids && fids.includes(activeFolderId);
});
}
/**
* Drills into a folder — hides folder grid, shows breadcrumb, filters images.
* @param {string} folderId
*/
function onFolderDrillIn(folderId) {
const folder = folderList.find(f => f.id === folderId);
if (!folder) return;
activeFolderId = folderId;
$('#Backgrounds').addClass('in-folder-view');
// Hide folder grid, show breadcrumb
$('#bg_folder_grid').hide();
$('#bg_folder_breadcrumb').show();
$('#bg_current_folder_name').text(folder.name);
// Render only this folder's images
renderSystemBackgrounds(getFilteredImages());
highlightSelectedBackground();
}
/**
* Returns to the root folder overview.
*/
function onBackToFolders() {
activeFolderId = null;
$('#Backgrounds').removeClass('in-folder-view');
// Show folder grid, hide breadcrumb
$('#bg_folder_grid').show();
$('#bg_folder_breadcrumb').hide();
$('#bg_current_folder_name').text('');
// Show all images
renderSystemBackgrounds(getFilteredImages());
highlightSelectedBackground();
}
/**
* Creates a new folder via API.
*/
async function onCreateFolder() {
const currentTab = getActiveBackgroundTab();
if (currentTab !== BG_SOURCES.GLOBAL) {
toastr.warning(t`Folders can only be created in the Global tab`);
return;
}
const name = await Popup.show.input(t`Enter folder name:`);
if (!name || !name.trim()) return;
try {
const response = await fetch('/api/image-metadata/folders/create', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name.trim() }),
});
if (response.ok) {
const folder = await response.json();
folderList.push(folder);
renderFolderGrid();
toastr.success(t`Folder created: ${folder.name}`);
}
} catch (error) {
console.error('Error creating folder:', error);
toastr.error(t`Failed to create folder`);
}
}
/**
* Renames a folder via API.
* @param {string} folderId
*/
async function onRenameFolder(folderId) {
const folder = folderList.find(f => f.id === folderId);
if (!folder) return;
const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name);
if (!newName || !newName.trim() || newName.trim() === folder.name) return;
try {
const response = await fetch('/api/image-metadata/folders/update', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: folderId, name: newName.trim() }),
});
if (response.ok) {
folder.name = newName.trim();
renderFolderGrid();
toastr.success(t`Folder renamed`);
}
} catch (error) {
console.error('Error renaming folder:', error);
toastr.error(t`Failed to rename folder`);
}
}
/**
* Deletes a folder via API.
* @param {string} folderId
*/
async function onDeleteFolder(folderId) {
const folder = folderList.find(f => f.id === folderId);
if (!folder) return;
const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`);
if (!confirm) return;
try {
const response = await fetch('/api/image-metadata/folders/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: folderId }),
});
if (response.ok) {
folderList = folderList.filter(f => f.id !== folderId);
// Clean imageFolderMap
for (const fids of Object.values(imageFolderMap)) {
const idx = fids.indexOf(folderId);
if (idx !== -1) fids.splice(idx, 1);
}
// If we were inside this folder, go back
if (activeFolderId === folderId) {
onBackToFolders();
}
renderFolderGrid();
toastr.success(t`Folder deleted`);
}
} catch (error) {
console.error('Error deleting folder:', error);
toastr.error(t`Failed to delete folder`);
}
}
/**
* Shows a folder assignment popup for an image.
* @param {string} bgFile - The background filename
*/
async function onAssignToFolder(bgFile) {
if (folderList.length === 0) {
toastr.info(t`Create a folder first`);
return;
}
const currentFolderIds = imageFolderMap[bgFile] || [];
// Build checkbox inputs for Popup using DOM construction (avoids HTML injection)
const contentEl = document.createElement('div');
const heading = document.createElement('h3');
heading.textContent = t`Assign to folders`;
contentEl.appendChild(heading);
for (const f 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 = f.id;
checkbox.checked = currentFolderIds.includes(f.id);
const span = document.createElement('span');
span.textContent = f.name;
label.appendChild(checkbox);
label.appendChild(span);
contentEl.appendChild(label);
}
const content = $(contentEl);
const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
if (!result) return;
// Determine which folders were toggled on/off
const toAssign = [];
const toUnassign = [];
content.find('input[type="checkbox"]').each(function () {
const fid = $(this).data('folder-id');
const isChecked = $(this).prop('checked');
const wasChecked = currentFolderIds.includes(fid);
if (isChecked && !wasChecked) toAssign.push(fid);
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}`);
}
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];
}
renderFolderGrid();
// Re-render filtered image list if currently inside a folder view
if (activeFolderId) {
renderSystemBackgrounds(getFilteredImages());
highlightSelectedBackground();
}
toastr.success(t`Folder assignment updated`);
} catch (error) {
console.error('Error assigning to folder:', error);
toastr.error(t`Failed to update folder assignment`);
}
}
/**
* Sets an image as the folder cover.
* @param {string} bgFile - The background filename
*/
async function onSetFolderCover(bgFile) {
if (!activeFolderId) return;
try {
const response = await fetch('/api/image-metadata/folders/update', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }),
});
if (response.ok) {
const folder = folderList.find(f => f.id === activeFolderId);
if (folder) {
folder.thumbnailFile = bgFile;
// Update the DOM tile cover image
const coverUrl = await getFolderCoverUrl(folder);
if (coverUrl) {
$(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`)
.css('background-image', `url('${coverUrl}')`);
}
}
toastr.success(t`Folder cover updated`);
}
} catch (error) {
console.error('Error setting folder cover:', error);
toastr.error(t`Failed to set folder cover`);
}
}
function activateLazyLoader() {
// Disconnect previous observer to prevent memory leaks
if (lazyLoadObserver) {
@@ -1001,6 +1425,21 @@ function onBackgroundFilterInput() {
const hasMatch = title.toLowerCase().includes(filterValue);
$bg.toggle(hasMatch);
});
// Show/hide folder tiles based on whether folder name matches the filter
if (!activeFolderId) {
$('#bg_folder_grid .bg_folder_tile').each(function () {
const $tile = $(this);
const folderId = $tile.attr('data-folder-id');
if (!folderId || !filterValue) {
$tile.show();
return;
}
const folder = folderList.find(f => f.id === folderId);
const folderName = folder ? folder.name.toLowerCase() : '';
$tile.toggle(folderName.includes(filterValue));
});
}
}
const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
@@ -1017,6 +1456,41 @@ export function initBackgrounds() {
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
// Folder event handlers
$(document)
.on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) {
if ($(e.target).closest('.jg-button').length) return; // let button handler run
const folderId = $(this).attr('data-folder-id');
if (folderId) onFolderDrillIn(folderId);
})
.on('click', '#bg_add_folder_button', function () {
onCreateFolder();
})
.on('click', '#bg_back_to_folders', function () {
onBackToFolders();
})
.on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) {
e.stopPropagation();
const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
if (folderId) onRenameFolder(folderId);
})
.on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) {
e.stopPropagation();
const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
if (folderId) onDeleteFolder(folderId);
})
.on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) {
e.stopPropagation();
const $context = $(this).closest('.bg_folder_tile');
const wasOpen = $context.hasClass('mobile-menu-open');
// Close all other open menus before opening a new one.
$('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
if (!wasOpen) {
$context.addClass('mobile-menu-open');
}
});
$(document)
.off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
.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() {
const wasOpen = $context.hasClass('mobile-menu-open');
// Close all other open menus before opening a new one.
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
$('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
if (!wasOpen) {
$context.addClass('mobile-menu-open');
}
@@ -1054,6 +1529,20 @@ export function initBackgrounds() {
case 'copy':
onCopyToSystemBackgroundClick.call(this, e.originalEvent);
break;
case 'folder': {
const bgEl = $(this).closest('.bg_example');
if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
const bgFile = bgEl.attr('bgfile');
if (bgFile) onAssignToFolder(bgFile);
break;
}
case 'set-cover': {
const bgEl = $(this).closest('.bg_example');
if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
const bgFile = bgEl.attr('bgfile');
if (bgFile) onSetFolderCover(bgFile);
break;
}
}
});
@@ -1069,8 +1558,8 @@ export function initBackgrounds() {
$('#bg-sort').on('change', function () {
background_settings.sortOrder = String($(this).val());
saveSettingsDebounced();
// Re-render both galleries with new sort order
renderSystemBackgrounds(cachedSystemBackgrounds);
// Re-render both galleries with new sort order (respecting active folder filter)
renderSystemBackgrounds(getFilteredImages());
renderChatBackgrounds();
highlightSelectedBackground();
highlightLockedBackground();
+29 -2
View File
@@ -5,7 +5,7 @@ import express from 'express';
import sanitize from 'sanitize-filename';
import { invalidateThumbnail } from './thumbnails.js';
import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js';
import { thumbnailDimensions, readMetadataIndex, renameMetadata, removeMetadata, getOrGenerateMetadataBatch } from './image-metadata.js';
import { getImages } from '../util.js';
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
@@ -37,6 +37,34 @@ router.post('/all', async function (request, response) {
}
});
/**
* POST /api/backgrounds/folders
* Returns folders and per-image folderIds from the metadata index.
* Loaded separately from /all to avoid blocking image rendering.
*/
router.post('/folders', async function (request, response) {
try {
const index = await readMetadataIndex(request.user.directories.root);
const folders = index.folders || [];
// Build a slim map of image → folderIds for the frontend
/** @type {Object.<string, string[]>} */
const imageFolderMap = {};
for (const [relativePath, meta] of Object.entries(index.images)) {
if (Array.isArray(meta.folderIds) && meta.folderIds.length > 0) {
// Strip the directory prefix to get just the filename
const filename = relativePath.split('/').pop() || relativePath;
imageFolderMap[filename] = meta.folderIds;
}
}
response.json({ folders, imageFolderMap });
} catch (error) {
console.error('[Backgrounds] Folders endpoint error:', error);
response.status(500).json({ error: 'Internal server error.' });
}
});
router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
try {
if (!request.body) return response.sendStatus(400);
@@ -110,7 +138,6 @@ router.post('/upload', async function (request, response) {
const img_path = path.join(request.file.destination, request.file.filename);
const filename = sanitize(request.file.originalname);
fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
fs.unlinkSync(img_path);
invalidateThumbnail(request.user.directories, 'bg', filename);
+300 -7
View File
@@ -10,19 +10,19 @@ import { imageSize } from 'image-size';
import writeFileAtomic from 'write-file-atomic';
import express from 'express';
import { Jimp } from '../jimp.js';
import { getConfigValue, isPathUnderParent } from '../util.js';
import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js';
export const METADATA_FILE = 'image-metadata.json';
/**
* @typedef {Object} ImageMetadata
* @property {string} hash - SHA-256 hash of the image file.
* @property {number} aspectRatio - Aspect ratio (width / height) of the image.
* @property {boolean} isAnimated - Whether the image is animated.
* @property {string} dominantColor - Dominant color in hex format (e.g., '#RRGGBB').
* @property {string} [hash] - SHA-256 hash of the image file.
* @property {number} [aspectRatio] - Aspect ratio (width / height) of the image.
* @property {boolean} [isAnimated] - Whether the image is animated.
* @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB').
* @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
* @property {number} addedTimestamp - Timestamp when the image was added.
* @property {number} thumbnailResolution - Thumbnail resolution (width * height) for cache invalidation.
* @property {number} [addedTimestamp] - Timestamp when the image was added.
* @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation.
* @property {number} [mtime] - File modification time for cache invalidation (internal use).
*/
@@ -255,6 +255,17 @@ export async function removeMetadata(userDataRoot, relativePath) {
const index = await readMetadataIndex(userDataRoot);
if (index.images[posixPath]) {
delete index.images[posixPath];
// Clear any folder thumbnailFile references that point to the deleted file
const deletedFileName = path.posix.basename(posixPath);
if (Array.isArray(index.folders)) {
for (const folder of index.folders) {
if (folder.thumbnailFile === deletedFileName) {
folder.thumbnailFile = '';
}
}
}
await writeMetadataIndex(userDataRoot, index);
}
}
@@ -278,6 +289,18 @@ export async function renameMetadata(userDataRoot, oldRelativePath, newRelativeP
delete index.images[posixOldPath];
index.images[posixNewPath] = data;
// Update any folder thumbnailFile references that point to the old filename
const oldFileName = path.posix.basename(posixOldPath);
const newFileName = path.posix.basename(posixNewPath);
if (oldFileName !== newFileName && Array.isArray(index.folders)) {
for (const folder of index.folders) {
if (folder.thumbnailFile === oldFileName) {
folder.thumbnailFile = newFileName;
}
}
}
await writeMetadataIndex(userDataRoot, index);
return data;
@@ -319,9 +342,279 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
return orphanedPaths;
}
/**
* Creates a new virtual folder.
* @param {string} userDataRoot
* @param {string} name
* @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
*/
export async function createFolder(userDataRoot, name) {
const index = await readMetadataIndex(userDataRoot);
const id = uuidv4();
const folder = { id, name, thumbnailFile: '' };
index.folders.push(folder);
await writeMetadataIndex(userDataRoot, index);
return folder;
}
/**
* Sets thumbnail files for multiple folders in a single atomic read-modify-write.
* Folders not found in the index are silently skipped.
* @param {string} userDataRoot
* @param {{id: string, thumbnailFile: string}[]} updates
* @returns {Promise<void>}
*/
export async function setFolderThumbnailsBatch(userDataRoot, updates) {
const index = await readMetadataIndex(userDataRoot);
for (const { id, thumbnailFile } of updates) {
const folder = index.folders.find(f => f.id === id);
if (folder) {
folder.thumbnailFile = thumbnailFile;
}
}
await writeMetadataIndex(userDataRoot, index);
}
/**
* Renames or updates a virtual folder.
* @param {string} userDataRoot
* @param {string} folderId
* @param {{name?: string, thumbnailFile?: string}} updates
* @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
*/
export async function updateFolder(userDataRoot, folderId, updates) {
const index = await readMetadataIndex(userDataRoot);
const folder = index.folders.find(f => f.id === folderId);
if (!folder) throw new Error(`Folder '${folderId}' not found.`);
if (updates.name !== undefined) folder.name = updates.name;
if (updates.thumbnailFile !== undefined) folder.thumbnailFile = updates.thumbnailFile;
await writeMetadataIndex(userDataRoot, index);
return folder;
}
/**
* Deletes a virtual folder and removes its ID from all images.
* @param {string} userDataRoot
* @param {string} folderId
* @returns {Promise<void>}
*/
export async function deleteFolder(userDataRoot, folderId) {
const index = await readMetadataIndex(userDataRoot);
const idx = index.folders.findIndex(f => f.id === folderId);
if (idx === -1) throw new Error(`Folder '${folderId}' not found.`);
index.folders.splice(idx, 1);
// Remove folderId from all images
for (const meta of Object.values(index.images)) {
if (Array.isArray(meta.folderIds)) {
const fi = meta.folderIds.indexOf(folderId);
if (fi !== -1) meta.folderIds.splice(fi, 1);
}
}
await writeMetadataIndex(userDataRoot, index);
}
/**
* Assigns images to a folder.
* @param {string} userDataRoot
* @param {string} folderId
* @param {string[]} relativePaths
* @returns {Promise<void>}
*/
export async function assignImagesToFolder(userDataRoot, folderId, relativePaths) {
const index = await readMetadataIndex(userDataRoot);
if (!index.folders.some(f => f.id === folderId)) {
throw new Error(`Folder '${folderId}' not found.`);
}
for (const rp of relativePaths) {
const posixPath = rp.replaceAll(path.sep, path.posix.sep);
// Validate: must be a backgrounds/ path, and no path-traversal segments
const normalized = path.posix.normalize(posixPath);
if (!normalized.startsWith('backgrounds/') || normalized.split('/').some(seg => seg === '..')) {
throw new Error(`Invalid background path: '${posixPath}'`);
}
// Validate: skip silently on missing files
const absPath = path.join(userDataRoot, normalized);
try {
await fs.access(absPath);
} catch {
console.warn(`[ImageMetadata] Skipping missing background file: '${posixPath}'`);
continue;
}
let meta = index.images[normalized];
if (!meta) {
// Create a stub entry so folderIds can be stored even before full metadata generation
meta = { folderIds: [] };
index.images[normalized] = meta;
}
if (!Array.isArray(meta.folderIds)) meta.folderIds = [];
if (!meta.folderIds.includes(folderId)) {
meta.folderIds.push(folderId);
}
}
await writeMetadataIndex(userDataRoot, index);
}
/**
* Unassigns images from a folder.
* @param {string} userDataRoot
* @param {string} folderId
* @param {string[]} relativePaths
* @returns {Promise<void>}
*/
export async function unassignImagesFromFolder(userDataRoot, folderId, relativePaths) {
const index = await readMetadataIndex(userDataRoot);
for (const rp of relativePaths) {
const posixPath = rp.replaceAll(path.sep, path.posix.sep);
const meta = index.images[posixPath];
if (!meta || !Array.isArray(meta.folderIds)) continue;
const fi = meta.folderIds.indexOf(folderId);
if (fi !== -1) meta.folderIds.splice(fi, 1);
}
await writeMetadataIndex(userDataRoot, index);
}
export const router = express.Router();
/**
* POST /api/image-metadata/folders/get
* List all virtual folders.
*/
router.post('/folders/get', async function (request, response) {
try {
const index = await readMetadataIndex(request.user.directories.root);
return response.json(index.folders || []);
} catch (error) {
console.error('[ImageMetadata] Folders list error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/create
* Create a new folder. Body: { name: string }
*/
router.post('/folders/create', async function (request, response) {
try {
const { name } = request.body;
if (!name || typeof name !== 'string') {
return response.status(400).json({ error: '"name" is required.' });
}
const folder = await createFolder(request.user.directories.root, name.trim());
return response.json(folder);
} catch (error) {
console.error('[ImageMetadata] Folder create error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/set-thumbnails
* Batch-set thumbnail files for multiple folders in one write. Body: { updates: [{id, thumbnailFile}] }
*/
router.post('/folders/set-thumbnails', async function (request, response) {
try {
const { updates } = request.body;
if (!Array.isArray(updates) || updates.some(u => !u.id || typeof u.thumbnailFile !== 'string')) {
return response.status(400).json({ error: '"updates" must be an array of {id, thumbnailFile}.' });
}
await setFolderThumbnailsBatch(request.user.directories.root, updates);
return response.json({ ok: true });
} catch (error) {
console.error('[ImageMetadata] Folder set-thumbnails error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/update
* Update a folder. Body: { id: string, name?: string, thumbnailFile?: string }
*/
router.post('/folders/update', async function (request, response) {
try {
const { id, ...updates } = request.body;
if (!id || typeof id !== 'string') {
return response.status(400).json({ error: '"id" is required.' });
}
const folder = await updateFolder(request.user.directories.root, id, updates);
return response.json(folder);
} catch (error) {
if (error.message.includes('not found')) {
return response.status(404).json({ error: error.message });
}
console.error('[ImageMetadata] Folder update error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/delete
* Delete a folder and unassign all images. Body: { id: string }
*/
router.post('/folders/delete', async function (request, response) {
try {
const { id } = request.body;
if (!id || typeof id !== 'string') {
return response.status(400).json({ error: '"id" is required.' });
}
await deleteFolder(request.user.directories.root, id);
return response.json({ ok: true });
} catch (error) {
if (error.message.includes('not found')) {
return response.status(404).json({ error: error.message });
}
console.error('[ImageMetadata] Folder delete error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/assign
* Assign images to a folder. Body: { id: string, paths: string[] }
*/
router.post('/folders/assign', async function (request, response) {
try {
const { id, paths } = request.body;
if (!id || typeof id !== 'string') {
return response.status(400).json({ error: '"id" is required.' });
}
if (!Array.isArray(paths)) {
return response.status(400).json({ error: '"paths" array is required.' });
}
await assignImagesToFolder(request.user.directories.root, id, paths);
return response.json({ ok: true });
} catch (error) {
if (error.message.includes('not found')) {
return response.status(404).json({ error: error.message });
}
console.error('[ImageMetadata] Folder assign error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata/folders/unassign
* Unassign images from a folder. Body: { id: string, paths: string[] }
*/
router.post('/folders/unassign', async function (request, response) {
try {
const { id, paths } = request.body;
if (!id || typeof id !== 'string') {
return response.status(400).json({ error: '"id" is required.' });
}
if (!Array.isArray(paths)) {
return response.status(400).json({ error: '"paths" array is required.' });
}
await unassignImagesFromFolder(request.user.directories.root, id, paths);
return response.json({ ok: true });
} catch (error) {
console.error('[ImageMetadata] Folder unassign error:', error);
return response.status(500).json({ error: 'Internal server error.' });
}
});
/**
* POST /api/image-metadata
* Get metadata for image(s) by path.