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