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>
Signed| @@ -107,7 +107,8 @@ | ||
| 107 | 107 | opacity: 1; |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | .bg_example .mobile-only-menu-toggle {, |
| 111 | +.bg_folder_tile .mobile-only-menu-toggle { | |
| 111 | 112 | display: none; |
| 112 | 113 | } |
| 113 | 114 | |
| @@ -376,3 +377,169 @@ | ||
| 376 | 377 | #bg_custom_content:not(:empty)~#bg_chat_hint { |
| 377 | 378 | display: none; |
| 378 | 379 | } |
| 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 | +} | |
| @@ -38,6 +38,56 @@ | ||
| 38 | 38 | grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr); |
| 39 | 39 | } |
| 40 | 40 | |
| 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 | + | |
| 41 | 91 | .bg_list { |
| 42 | 92 | width: unset; |
| 43 | 93 | } |
| @@ -102,7 +152,8 @@ | ||
| 102 | 152 | } |
| 103 | 153 | |
| 104 | 154 | #add_background_button_top>span, |
| 105 | 155 | #auto_background>span {, |
| 156 | + #bg_add_folder_button>span { | |
| 106 | 157 | display: none; |
| 107 | 158 | } |
| 108 | 159 | |
| @@ -5597,6 +5597,10 @@ | ||
| 5597 | 5597 | <i class="fa-solid fa-wand-magic"></i> |
| 5598 | 5598 | <span data-i18n="Auto-select">Auto-select</span> |
| 5599 | 5599 | </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> | |
| 5600 | 5604 | <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"> |
| 5601 | 5605 | <i class="fa-solid fa-upload"></i> |
| 5602 | 5606 | <span data-i18n="Add Background">Add Background</span> |
| @@ -5630,6 +5634,14 @@ | ||
| 5630 | 5634 | </div> |
| 5631 | 5635 | </ul> |
| 5632 | 5636 | <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> | |
| 5633 | 5645 | <div id="bg_menu_content" class="bg_list"></div> |
| 5634 | 5646 | </div> |
| 5635 | 5647 | <div id="bg_chat_tab" class="bg_tab_panel"> |
| @@ -6598,10 +6610,34 @@ | ||
| 6598 | 6610 | <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> |
| 6599 | 6611 | <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> |
| 6600 | 6612 | <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> | |
| 6601 | 6615 | </div> |
| 6602 | 6616 | <div class="BGSampleTitle"></div> |
| 6603 | 6617 | </div> |
| 6604 | 6618 | </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> | |
| 6605 | 6641 | <!-- templates for JS to reuse when needed --> |
| 6606 | 6642 | <div id="character_world_template" class="template_element"> |
| 6607 | 6643 | <div class="character_world range-block flexFlowColumn flex-container"> |
| @@ -6,7 +6,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | ||
| 6 | 6 | import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js'; |
| 7 | 7 | import { debounce_timeout } from './constants.js'; |
| 8 | 8 | import { t } from './i18n.js'; |
| 9 | 9 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; |
| 10 | 10 | import { groups, selected_group } from './group-chats.js'; |
| 11 | 11 | import { humanizedDateTime } from './RossAscends-mods.js'; |
| 12 | 12 | import { deleteMediaFromServer } from './chats.js'; |
| @@ -14,6 +14,13 @@ import { deleteMediaFromServer } from './chats.js'; | ||
| 14 | 14 | const BG_METADATA_KEY = 'custom_background'; |
| 15 | 15 | const LIST_METADATA_KEY = 'chat_backgrounds'; |
| 16 | 16 | |
| 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 | + | |
| 17 | 24 | // A single transparent PNG pixel used as a placeholder for errored backgrounds |
| 18 | 25 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 19 | 26 | 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) { | ||
| 170 | 177 | |
| 171 | 178 | const url = generateUrlParameter(bg, isCustom); |
| 172 | 179 | const title = isCustom ? bg.split('/').pop() : bg; |
| 173 | 180 | const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.')); |
| 174 | 181 | |
| 175 | 182 | thumbnail.attr('title', title); |
| 176 | 183 | thumbnail.attr('bgfile', bg); |
| @@ -278,6 +285,17 @@ export function getBackgroundPath(fileUrl) { | ||
| 278 | 285 | return `backgrounds/${encodeURIComponent(fileUrl)}`; |
| 279 | 286 | } |
| 280 | 287 | |
| 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 | + | |
| 281 | 299 | function highlightLockedBackground() { |
| 282 | 300 | $('.bg_example.locked-background').removeClass('locked-background'); |
| 283 | 301 | |
| @@ -553,6 +571,24 @@ async function onDeleteBackgroundClick(e) { | ||
| 553 | 571 | } |
| 554 | 572 | } |
| 555 | 573 | |
| 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 | + | |
| 556 | 592 | bgToDelete.remove(); |
| 557 | 593 | |
| 558 | 594 | if (url === chat_metadata[BG_METADATA_KEY]) { |
| @@ -664,9 +700,14 @@ export async function getBackgrounds() { | ||
| 664 | 700 | const { images, config } = await response.json(); |
| 665 | 701 | Object.assign(THUMBNAIL_CONFIG, config); |
| 666 | 702 | cachedSystemBackgrounds = images; |
| 703 | + | |
| 704 | + // Load folders first so getFilteredImages() works correctly in folder view | |
| 705 | + await loadFolders(); | |
| 706 | + | |
| 667 | 707 | await preloadImageMetadata(); |
| 668 | 708 | |
| 669 | - renderSystemBackgrounds(images); | |
| 709 | + // Render only filtered images if inside a folder, otherwise all | |
| 710 | + renderSystemBackgrounds(getFilteredImages()); | |
| 670 | 711 | highlightSelectedBackground(); |
| 671 | 712 | } |
| 672 | 713 | } |
| @@ -696,6 +737,389 @@ async function preloadImageMetadata() { | ||
| 696 | 737 | } |
| 697 | 738 | } |
| 698 | 739 | |
| 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 | + | |
| 699 | 1123 | function activateLazyLoader() { |
| 700 | 1124 | // Disconnect previous observer to prevent memory leaks |
| 701 | 1125 | if (lazyLoadObserver) { |
| @@ -1001,6 +1425,21 @@ function onBackgroundFilterInput() { | ||
| 1001 | 1425 | const hasMatch = title.toLowerCase().includes(filterValue); |
| 1002 | 1426 | $bg.toggle(hasMatch); |
| 1003 | 1427 | }); |
| 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 | + } | |
| 1004 | 1443 | } |
| 1005 | 1444 | |
| 1006 | 1445 | const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard); |
| @@ -1017,6 +1456,41 @@ export function initBackgrounds() { | ||
| 1017 | 1456 | eventSource.on(event_types.CHAT_CHANGED, onChatChanged); |
| 1018 | 1457 | eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground); |
| 1019 | 1458 | |
| 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 | + | |
| 1020 | 1494 | $(document) |
| 1021 | 1495 | .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick) |
| 1022 | 1496 | .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() { | ||
| 1025 | 1499 | const wasOpen = $context.hasClass('mobile-menu-open'); |
| 1026 | 1500 | // Close all other open menus before opening a new one. |
| 1027 | 1501 | $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open'); |
| 1502 | + $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open'); | |
| 1028 | 1503 | if (!wasOpen) { |
| 1029 | 1504 | $context.addClass('mobile-menu-open'); |
| 1030 | 1505 | } |
| @@ -1054,6 +1529,20 @@ export function initBackgrounds() { | ||
| 1054 | 1529 | case 'copy': |
| 1055 | 1530 | onCopyToSystemBackgroundClick.call(this, e.originalEvent); |
| 1056 | 1531 | 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 | + } | |
| 1057 | 1546 | } |
| 1058 | 1547 | }); |
| 1059 | 1548 | |
| @@ -1069,8 +1558,8 @@ export function initBackgrounds() { | ||
| 1069 | 1558 | $('#bg-sort').on('change', function () { |
| 1070 | 1559 | background_settings.sortOrder = String($(this).val()); |
| 1071 | 1560 | saveSettingsDebounced(); |
| 1072 | 1561 | // Re-render both galleries with new sort order (respecting active folder filter) |
| 1073 | 1562 | renderSystemBackgrounds(cachedSystemBackgroundsgetFilteredImages()); |
| 1074 | 1563 | renderChatBackgrounds(); |
| 1075 | 1564 | highlightSelectedBackground(); |
| 1076 | 1565 | highlightLockedBackground(); |
| @@ -5,7 +5,7 @@ import express from 'express'; | ||
| 5 | 5 | import sanitize from 'sanitize-filename'; |
| 6 | 6 | |
| 7 | 7 | import { invalidateThumbnail } from './thumbnails.js'; |
| 8 | 8 | import { getOrGenerateMetadataBatchthumbnailDimensions, removeMetadatareadMetadataIndex, renameMetadata, thumbnailDimensionsremoveMetadata, getOrGenerateMetadataBatch } from './image-metadata.js'; |
| 9 | 9 | import { getImages } from '../util.js'; |
| 10 | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | 11 | |
| @@ -37,6 +37,34 @@ router.post('/all', async function (request, response) { | ||
| 37 | 37 | } |
| 38 | 38 | }); |
| 39 | 39 | |
| 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 | + | |
| 40 | 68 | router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) { |
| 41 | 69 | try { |
| 42 | 70 | if (!request.body) return response.sendStatus(400); |
| @@ -110,7 +138,6 @@ router.post('/upload', async function (request, response) { | ||
| 110 | 138 | |
| 111 | 139 | const img_path = path.join(request.file.destination, request.file.filename); |
| 112 | 140 | const filename = sanitize(request.file.originalname); |
| 113 | - | |
| 114 | 141 | fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename)); |
| 115 | 142 | fs.unlinkSync(img_path); |
| 116 | 143 | invalidateThumbnail(request.user.directories, 'bg', filename); |
| @@ -10,19 +10,19 @@ import { imageSize } from 'image-size'; | ||
| 10 | 10 | import writeFileAtomic from 'write-file-atomic'; |
| 11 | 11 | import express from 'express'; |
| 12 | 12 | import { Jimp } from '../jimp.js'; |
| 13 | 13 | import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js'; |
| 14 | 14 | |
| 15 | 15 | export const METADATA_FILE = 'image-metadata.json'; |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | 18 | * @typedef {Object} ImageMetadata |
| 19 | 19 | * @property {string} [hash] - SHA-256 hash of the image file. |
| 20 | 20 | * @property {number} [aspectRatio] - Aspect ratio (width / height) of the image. |
| 21 | 21 | * @property {boolean} [isAnimated] - Whether the image is animated. |
| 22 | 22 | * @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB'). |
| 23 | 23 | * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to. |
| 24 | 24 | * @property {number} [addedTimestamp] - Timestamp when the image was added. |
| 25 | 25 | * @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation. |
| 26 | 26 | * @property {number} [mtime] - File modification time for cache invalidation (internal use). |
| 27 | 27 | */ |
| 28 | 28 | |
| @@ -255,6 +255,17 @@ export async function removeMetadata(userDataRoot, relativePath) { | ||
| 255 | 255 | const index = await readMetadataIndex(userDataRoot); |
| 256 | 256 | if (index.images[posixPath]) { |
| 257 | 257 | 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 | + | |
| 258 | 269 | await writeMetadataIndex(userDataRoot, index); |
| 259 | 270 | } |
| 260 | 271 | } |
| @@ -278,6 +289,18 @@ export async function renameMetadata(userDataRoot, oldRelativePath, newRelativeP | ||
| 278 | 289 | |
| 279 | 290 | delete index.images[posixOldPath]; |
| 280 | 291 | 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 | + | |
| 281 | 304 | await writeMetadataIndex(userDataRoot, index); |
| 282 | 305 | |
| 283 | 306 | return data; |
| @@ -319,10 +342,280 @@ export async function cleanupOrphanedMetadata(userDataRoot) { | ||
| 319 | 342 | return orphanedPaths; |
| 320 | 343 | } |
| 321 | 344 | |
| 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 | +} | |
| 322 | 478 | |
| 323 | 479 | export const router = express.Router(); |
| 324 | 480 | |
| 325 | 481 | /** |
| 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 | +/** | |
| 326 | 619 | * POST /api/image-metadata |
| 327 | 620 | * Get metadata for image(s) by path. |
| 328 | 621 | */ |