Merge branch 'staging' into char-shallow

c48ecc67b2c4492d86a911631e1e41742d2ef2b7

Cohee <18619528+Cohee1207@users.noreply.github.com>

4 files changed, +418 -81Ignore whitespace
public/scripts/extensions.js+6 -0
@@ -210,6 +210,12 @@ export const extension_settings = {
210 * @type {string[]}210 * @type {string[]}
211 */211 */
212 disabled_attachments: [],212 disabled_attachments: [],
213 gallery: {
214 /** @type {{[characterKey: string]: string}} */
215 folders: {},
216 /** @type {string} */
217 sort: 'dateAsc',
218 },
213};219};
214220
215function showHideExtensionsMenu() {221function showHideExtensionsMenu() {
public/scripts/extensions/gallery/index.js+329 -74
@@ -6,7 +6,7 @@ import {
6 event_types,6 event_types,
7} from '../../../script.js';7} from '../../../script.js';
8import { groups, selected_group } from '../../group-chats.js';8import { groups, selected_group } from '../../group-chats.js';
9import { loadFileToDocument, delay } from '../../utils.js';9import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js';
10import { loadMovingUIState } from '../../power-user.js';10import { loadMovingUIState } from '../../power-user.js';
11import { dragElement } from '../../RossAscends-mods.js';11import { dragElement } from '../../RossAscends-mods.js';
12import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';12import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
14import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';14import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
15import { DragAndDropHandler } from '../../dragdrop.js';15import { DragAndDropHandler } from '../../dragdrop.js';
16import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';16import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
17import { translate } from '../../i18n.js';17import { t, translate } from '../../i18n.js';
1818
19const extensionName = 'gallery';19const extensionName = 'gallery';
20const extensionFolderPath = `scripts/extensions/${extensionName}/`;20const extensionFolderPath = `scripts/extensions/${extensionName}/`;
@@ -50,6 +50,48 @@ mutationObserver.observe(document.body, {
50 subtree: false,50 subtree: false,
51});51});
5252
53const SORT = Object.freeze({
54 NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` },
55 NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` },
56 DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` },
57 DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` },
58});
59
60const defaultSettings = Object.freeze({
61 folders: {},
62 sort: SORT.DATE_ASC.value,
63});
64
65/**
66 * Initializes the settings for the gallery extension.
67 */
68function initSettings() {
69 let shouldSave = false;
70 const context = SillyTavern.getContext();
71 if (!context.extensionSettings.gallery) {
72 context.extensionSettings.gallery = structuredClone(defaultSettings);
73 shouldSave = true;
74 }
75 for (const key of Object.keys(defaultSettings)) {
76 if (!Object.hasOwn(context.extensionSettings.gallery, key)) {
77 context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]);
78 shouldSave = true;
79 }
80 }
81 if (shouldSave) {
82 context.saveSettingsDebounced();
83 }
84}
85
86/**
87 * Retrieves the gallery folder for a given character.
88 * @param {import('../../char-data.js').v1CharData} char Character data
89 * @returns {string} The gallery folder for the character
90 */
91function getGalleryFolder(char) {
92 return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name;
93}
94
53/**95/**
54 * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint96 * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
55 * to get the filenames and then constructs the item list.97 * to get the filenames and then constructs the item list.
@@ -58,11 +100,20 @@ mutationObserver.observe(document.body, {
58 * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.100 * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
59 */101 */
60async function getGalleryItems(url) {102async function getGalleryItems(url) {
61 const response = await fetch(`/api/images/list/${url}`, {103 const sortValue = getSortOrder();
104 const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC;
105 const response = await fetch('/api/images/list', {
62 method: 'POST',106 method: 'POST',
63 headers: getRequestHeaders(),107 headers: getRequestHeaders(),
108 body: JSON.stringify({
109 folder: url,
110 sortField: sortObj.field,
111 sortOrder: sortObj.order,
112 }),
64 });113 });
65114
115 url = await getSanitizedFilename(url);
116
66 const data = await response.json();117 const data = await response.json();
67 const items = data.map((file) => ({118 const items = data.map((file) => ({
68 src: `user/images/${url}/${file}`,119 src: `user/images/${url}/${file}`,
@@ -74,6 +125,46 @@ async function getGalleryItems(url) {
74}125}
75126
76/**127/**
128 * Retrieves a list of gallery folders. This function calls an API endpoint
129 * @returns {Promise<string[]>} - Resolves with an array of gallery folders.
130 */
131async function getGalleryFolders() {
132 try {
133 const response = await fetch('/api/images/folders', {
134 method: 'POST',
135 headers: getRequestHeaders(),
136 });
137
138 if (!response.ok) {
139 throw new Error(`HTTP error. Status: ${response.status}`);
140 }
141 const data = await response.json();
142 return data;
143 } catch (error) {
144 console.error('Failed to fetch gallery folders:', error);
145 return [];
146 }
147}
148
149/**
150 * Sets the sort order for the gallery.
151 * @param {string} order Sort order
152 */
153function setSortOrder(order) {
154 const context = SillyTavern.getContext();
155 context.extensionSettings.gallery.sort = order;
156 context.saveSettingsDebounced();
157}
158
159/**
160 * Retrieves the current sort order for the gallery.
161 * @returns {string} The current sort order for the gallery.
162 */
163function getSortOrder() {
164 return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort;
165}
166
167/**
77 * Initializes a gallery using the provided items and sets up the drag-and-drop functionality.168 * Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
78 * It uses the nanogallery2 library to display the items and also initializes169 * It uses the nanogallery2 library to display the items and also initializes
79 * event listeners to handle drag-and-drop of files onto the gallery.170 * event listeners to handle drag-and-drop of files onto the gallery.
@@ -106,11 +197,28 @@ async function initGallery(items, url) {
106 },197 },
107 galleryDisplayMode: 'pagination',198 galleryDisplayMode: 'pagination',
108 fnThumbnailOpen: viewWithDragbox,199 fnThumbnailOpen: viewWithDragbox,
200 fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) {
201 if (!item?.src) return;
202 $thumbnail.attr('title', String(item.src).split('/').pop());
203 },
109 });204 });
110205
111 const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files, event) => {206 const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => {
112 let file = files[0];207 if (!Array.isArray(files) || files.length === 0) {
113 uploadFile(file, url); // Added url parameter to know where to upload208 return;
209 }
210
211 // Upload each file
212 for (const file of files) {
213 await uploadFile(file, url);
214 }
215
216 // Refresh the gallery
217 const newItems = await getGalleryItems(url);
218 $('#dragGallery').closest('#gallery').remove();
219 await makeMovable(url);
220 await delay(100);
221 await initGallery(newItems, url);
114 });222 });
115223
116 const resizeHandler = function () {224 const resizeHandler = function () {
@@ -170,14 +278,13 @@ async function showCharGallery() {
170 try {278 try {
171 let url = selected_group || this_chid;279 let url = selected_group || this_chid;
172 if (!selected_group && this_chid !== undefined) {280 if (!selected_group && this_chid !== undefined) {
173 const char = characters[this_chid];281 url = getGalleryFolder(characters[this_chid]);
174 url = char.name;
175 }282 }
176283
177 const items = await getGalleryItems(url);284 const items = await getGalleryItems(url);
178 // if there already is a gallery, destroy it and place this one in its place285 // if there already is a gallery, destroy it and place this one in its place
179 $('#dragGallery').closest('#gallery').remove();286 $('#dragGallery').closest('#gallery').remove();
180 makeMovable();287 await makeMovable(url);
181 await delay(100);288 await delay(100);
182 await initGallery(items, url);289 await initGallery(items, url);
183 } catch (err) {290 } catch (err) {
@@ -196,92 +303,79 @@ async function showCharGallery() {
196 * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.303 * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
197 */304 */
198async function uploadFile(file, url) {305async function uploadFile(file, url) {
199 // Convert the file to a base64 string306 try {
200 const reader = new FileReader();307 // Convert the file to a base64 string
201 reader.onloadend = async function () {308 const base64Data = await getBase64Async(file);
202 const base64Data = reader.result;
203309
204 // Create the payload310 // Create the payload
205 const payload = {311 const payload = {
206 image: base64Data,312 image: base64Data,
313 ch_name: url,
207 };314 };
208315
209 // Add the ch_name from the provided URL (assuming it's the character name)316 const response = await fetch('/api/images/upload', {
210 payload.ch_name = url;317 method: 'POST',
211318 headers: getRequestHeaders(),
212 try {319 body: JSON.stringify(payload),
213 const headers = await getRequestHeaders();320 });
214
215 // Merge headers with content-type for JSON
216 Object.assign(headers, {
217 'Content-Type': 'application/json',
218 });
219
220 const response = await fetch('/api/images/upload', {
221 method: 'POST',
222 headers: headers,
223 body: JSON.stringify(payload),
224 });
225
226 if (!response.ok) {
227 throw new Error(`HTTP error! Status: ${response.status}`);
228 }
229321
230 const result = await response.json();322 if (!response.ok) {
323 throw new Error(`HTTP error! Status: ${response.status}`);
324 }
231325
232 toastr.success('File uploaded successfully. Saved at: ' + result.path);326 const result = await response.json();
233327
234 // Refresh the gallery328 toastr.success(t`File uploaded successfully. Saved at: ${result.path}`);
235 const newItems = await getGalleryItems(url); // Fetch the latest items329 } catch (error) {
236 $('#dragGallery').closest('#gallery').remove(); // Destroy old gallery330 console.error('There was an issue uploading the file:', error);
237 makeMovable();
238 await delay(100);
239 await initGallery(newItems, url); // Reinitialize the gallery with new items and pass 'url'
240 } catch (error) {
241 console.error('There was an issue uploading the file:', error);
242331
243 // Replacing alert with toastr error notification332 // Replacing alert with toastr error notification
244 toastr.error('Failed to upload the file.');333 toastr.error(t`Failed to upload the file.`);
245 }334 }
246 };
247 reader.readAsDataURL(file);
248}335}
249336
250$(document).ready(function () {
251 // Register an event listener
252 eventSource.on('charManagementDropdown', (selectedOptionId) => {
253 if (selectedOptionId === 'show_char_gallery') {
254 showCharGallery();
255 }
256 });
257
258 // Add an option to the dropdown
259 $('#char-management-dropdown').append(
260 $('<option>', {
261 id: 'show_char_gallery',
262 text: translate('Show Gallery'),
263 }),
264 );
265});
266
267/**337/**
268 * Creates a new draggable container based on a template.338 * Creates a new draggable container based on a template.
269 * This function takes a template with the ID 'generic_draggable_template' and clones it.339 * This function takes a template with the ID 'generic_draggable_template' and clones it.
270 * The cloned element has its attributes set, a new child div appended, and is made visible on the body.340 * The cloned element has its attributes set, a new child div appended, and is made visible on the body.
271 * Additionally, it sets up the element to prevent dragging on its images.341 * Additionally, it sets up the element to prevent dragging on its images.
342 * @param {string} url - The URL of the image source.
343 * @returns {Promise<void>} - Promise representing the completion of the draggable container creation.
272 */344 */
273function makeMovable(id = 'gallery') {345async function makeMovable(url) {
274
275 console.debug('making new container from template');346 console.debug('making new container from template');
347 const id = 'gallery';
276 const template = $('#generic_draggable_template').html();348 const template = $('#generic_draggable_template').html();
277 const newElement = $(template);349 const newElement = $(template);
278 newElement.css('background-color', 'var(--SmartThemeBlurTintColor)');350 newElement.css('background-color', 'var(--SmartThemeBlurTintColor)');
279 newElement.attr('forChar', id);351 newElement.attr('forChar', id);
280 newElement.attr('id', id);352 newElement.attr('id', id);
281 newElement.find('.drag-grabber').attr('id', `${id}header`);353 newElement.find('.drag-grabber').attr('id', `${id}header`);
282 newElement.find('.dragTitle').text('Image Gallery');354 const dragTitle = newElement.find('.dragTitle');
283 //add a div for the gallery355 dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline');
284 newElement.append('<div id="dragGallery"></div>');356 const titleText = document.createElement('span');
357 titleText.textContent = t`Image Gallery`;
358 dragTitle.append(titleText);
359 const sortSelect = document.createElement('select');
360 sortSelect.classList.add('gallery-sort-select');
361
362 for (const sort of Object.values(SORT)) {
363 const option = document.createElement('option');
364 option.value = sort.value;
365 option.textContent = sort.label;
366 sortSelect.appendChild(option);
367 }
368
369 sortSelect.addEventListener('change', async () => {
370 const selectedOption = sortSelect.options[sortSelect.selectedIndex].value;
371 setSortOrder(selectedOption);
372 closeButton.trigger('click');
373 await showCharGallery();
374 });
375
376 sortSelect.value = getSortOrder();
377 dragTitle.append(sortSelect);
378
285 // add no-scrollbar class to this element379 // add no-scrollbar class to this element
286 newElement.addClass('no-scrollbar');380 newElement.addClass('no-scrollbar');
287381
@@ -290,6 +384,81 @@ function makeMovable(id = 'gallery') {
290 closeButton.attr('id', `${id}close`);384 closeButton.attr('id', `${id}close`);
291 closeButton.attr('data-related-id', `${id}`);385 closeButton.attr('data-related-id', `${id}`);
292386
387 const topBarElement = document.createElement('div');
388 topBarElement.classList.add('flex-container', 'alignItemsCenter');
389
390 const onChangeFolder = async (/** @type {Event} */ e) => {
391 if (e instanceof KeyboardEvent && e.key !== 'Enter') {
392 return;
393 }
394
395 try {
396 const newUrl = await getSanitizedFilename(galleryFolderInput.value);
397 updateGalleryFolder(newUrl);
398 closeButton.trigger('click');
399 await showCharGallery();
400 toastr.info(t`Gallery folder changed to ${newUrl}`);
401 galleryFolderInput.value = newUrl;
402 } catch (error) {
403 console.error('Failed to change gallery folder:', error);
404 toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`);
405 }
406 };
407
408 const onRestoreFolder = async () => {
409 try {
410 restoreGalleryFolder();
411 closeButton.trigger('click');
412 await showCharGallery();
413 } catch (error) {
414 console.error('Failed to restore gallery folder:', error);
415 toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`);
416 }
417 };
418
419 const galleryFolderInput = document.createElement('input');
420 galleryFolderInput.type = 'text';
421 galleryFolderInput.placeholder = t`Folder Name`;
422 galleryFolderInput.title = t`Enter a folder name to change the gallery folder`;
423 galleryFolderInput.value = url;
424 galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1');
425 galleryFolderInput.addEventListener('keyup', onChangeFolder);
426
427 const galleryFolderAccept = document.createElement('div');
428 galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw');
429 galleryFolderAccept.title = t`Change gallery folder`;
430 galleryFolderAccept.addEventListener('click', onChangeFolder);
431
432 const galleryFolderRestore = document.createElement('div');
433 galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw');
434 galleryFolderRestore.title = t`Restore gallery folder`;
435 galleryFolderRestore.addEventListener('click', onRestoreFolder);
436
437 topBarElement.appendChild(galleryFolderInput);
438 topBarElement.appendChild(galleryFolderAccept);
439 topBarElement.appendChild(galleryFolderRestore);
440 newElement.append(topBarElement);
441
442 // Populate the gallery folder input with a list of available folders
443 const folders = await getGalleryFolders();
444 $(galleryFolderInput)
445 .autocomplete({
446 source: (i, o) => {
447 const term = i.term.toLowerCase();
448 const filtered = folders.filter(f => f.toLowerCase().includes(term));
449 o(filtered);
450 },
451 select: (e, u) => {
452 galleryFolderInput.value = u.item.value;
453 onChangeFolder(e);
454 },
455 minLength: 0,
456 })
457 .on('focus', () => $(galleryFolderInput).autocomplete('search', ''));
458
459 //add a div for the gallery
460 newElement.append('<div id="dragGallery"></div>');
461
293 $('#dragGallery').css('display', 'block');462 $('#dragGallery').css('display', 'block');
294463
295 $('#movingDivs').append(newElement);464 $('#movingDivs').append(newElement);
@@ -306,6 +475,59 @@ function makeMovable(id = 'gallery') {
306}475}
307476
308/**477/**
478 * Sets the gallery folder to a new URL.
479 * @param {string} newUrl - The new URL to set for the gallery folder.
480 */
481function updateGalleryFolder(newUrl) {
482 if (!newUrl) {
483 throw new Error('Folder name cannot be empty');
484 }
485 const context = SillyTavern.getContext();
486 if (context.groupId) {
487 throw new Error('Cannot change gallery folder in group chat');
488 }
489 if (context.characterId === undefined) {
490 throw new Error('Character is not selected');
491 }
492 const avatar = context.characters[context.characterId]?.avatar;
493 const name = context.characters[context.characterId]?.name;
494 if (!avatar) {
495 throw new Error('Character PNG ID is not found');
496 }
497 if (newUrl === name) {
498 // Default folder name is picked, remove the override
499 delete context.extensionSettings.gallery.folders[avatar];
500 } else {
501 // Custom folder name is provided, set the override
502 context.extensionSettings.gallery.folders[avatar] = newUrl;
503 }
504 context.saveSettingsDebounced();
505}
506
507/**
508 * Restores the gallery folder to the default value.
509 */
510function restoreGalleryFolder() {
511 const context = SillyTavern.getContext();
512 if (context.groupId) {
513 throw new Error('Cannot change gallery folder in group chat');
514 }
515 if (context.characterId === undefined) {
516 throw new Error('Character is not selected');
517 }
518 const avatar = context.characters[context.characterId]?.avatar;
519 if (!avatar) {
520 throw new Error('Character PNG ID is not found');
521 }
522 const existingOverride = context.extensionSettings.gallery.folders[avatar];
523 if (!existingOverride) {
524 throw new Error('No folder override found');
525 }
526 delete context.extensionSettings.gallery.folders[avatar];
527 context.saveSettingsDebounced();
528}
529
530/**
309 * Creates a new draggable image based on a template.531 * Creates a new draggable image based on a template.
310 *532 *
311 * This function clones a provided template with the ID 'generic_draggable_template',533 * This function clones a provided template with the ID 'generic_draggable_template',
@@ -331,7 +553,7 @@ function makeDragImg(id, url) {
331 const imgElem = document.createElement('img');553 const imgElem = document.createElement('img');
332 imgElem.src = url;554 imgElem.src = url;
333 let uniqueId = `draggable_${id}`;555 let uniqueId = `draggable_${id}`;
334 const draggableElem = newElement.querySelector('.draggable');556 const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
335 if (draggableElem) {557 if (draggableElem) {
336 draggableElem.appendChild(imgElem);558 draggableElem.appendChild(imgElem);
337559
@@ -351,7 +573,7 @@ function makeDragImg(id, url) {
351573
352 // Add an id to the close button574 // Add an id to the close button
353 // If the close button exists, set related-id575 // If the close button exists, set related-id
354 const closeButton = draggableElem.querySelector('.dragClose');576 const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose'));
355 if (closeButton) {577 if (closeButton) {
356 closeButton.id = `${uniqueId}close`;578 closeButton.id = `${uniqueId}close`;
357 closeButton.dataset.relatedId = uniqueId;579 closeButton.dataset.relatedId = uniqueId;
@@ -456,8 +678,7 @@ async function listGalleryCommand(args) {
456 try {678 try {
457 let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);679 let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
458 if (!args.char && !args.group && !selected_group && this_chid !== undefined) {680 if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
459 const char = characters[this_chid];681 url = getGalleryFolder(characters[this_chid]);
460 url = char.name;
461 }682 }
462683
463 const items = await getGalleryItems(url);684 const items = await getGalleryItems(url);
@@ -469,3 +690,37 @@ async function listGalleryCommand(args) {
469 }690 }
470 return JSON.stringify([]);691 return JSON.stringify([]);
471}692}
693
694// On extension load, ensure the settings are initialized
695(function () {
696 initSettings();
697 eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
698 const context = SillyTavern.getContext();
699 const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar];
700 if (galleryFolder) {
701 context.extensionSettings.gallery.folders[newAvatar] = galleryFolder;
702 delete context.extensionSettings.gallery.folders[oldAvatar];
703 context.saveSettingsDebounced();
704 }
705 });
706 eventSource.on(event_types.CHARACTER_DELETED, (data) => {
707 const avatar = data?.character?.avatar;
708 if (!avatar) return;
709 const context = SillyTavern.getContext();
710 delete context.extensionSettings.gallery.folders[avatar];
711 context.saveSettingsDebounced();
712 });
713 eventSource.on('charManagementDropdown', (selectedOptionId) => {
714 if (selectedOptionId === 'show_char_gallery') {
715 showCharGallery();
716 }
717 });
718
719 // Add an option to the dropdown
720 $('#char-management-dropdown').append(
721 $('<option>', {
722 id: 'show_char_gallery',
723 text: translate('Show Gallery'),
724 }),
725 );
726})();
public/scripts/extensions/gallery/style.css+40 -0
@@ -3,3 +3,43 @@
3 align-items: center;3 align-items: center;
4 justify-content: center;4 justify-content: center;
5}5}
6
7.gallery-folder-input {
8 background-color: transparent;
9 font-size: calc(var(--mainFontSize)* 0.9);
10 opacity: 0.8;
11 flex-grow: 1;
12}
13
14.gallery-folder-input:placeholder-shown {
15 font-style: italic;
16 opacity: 0.5;
17 border-color: transparent;
18}
19
20.gallery-sort-select {
21 width: max-content;
22 flex: 1;
23 cursor: pointer;
24 overflow-x: hidden;
25 white-space: nowrap;
26 text-overflow: ellipsis;
27 width: 100%;
28 opacity: 0.8;
29 background: none;
30 background-image: url(/img/down-arrow.svg);
31 background-repeat: no-repeat;
32 background-position: right 6px center;
33 background-size: 8px 5px;
34 padding-right: 20px;
35 font-size: calc(var(--mainFontSize)* 0.9);
36 margin-bottom: 0;
37}
38
39#gallery .dragTitle {
40 margin-right: 30px;
41}
42
43#dragGallery {
44 min-height: 25dvh;
45}
src/endpoints/images.js+43 -7
@@ -76,18 +76,54 @@ router.post('/upload', jsonParser, async (request, response) => {
76 }76 }
77});77});
7878
79router.post('/list/:folder', (request, response) => {79router.post('/list/:folder?', jsonParser, (request, response) => {
80 const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder));80 try {
81 if (request.params.folder) {
82 if (request.body.folder) {
83 return response.status(400).send({ error: 'Folder specified in both URL and body' });
84 }
8185
82 if (!fs.existsSync(directoryPath)) {86 console.warn('Deprecated: Use POST /api/images/list with folder in request body');
83 fs.mkdirSync(directoryPath, { recursive: true });87 request.body.folder = request.params.folder;
84 }88 }
8589
86 try {90 if (!request.body.folder) {
87 const images = getImages(directoryPath, 'date');91 return response.status(400).send({ error: 'No folder specified' });
92 }
93
94 const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));
95 const sort = request.body.sortField || 'date';
96 const order = request.body.sortOrder || 'asc';
97
98 if (!fs.existsSync(directoryPath)) {
99 fs.mkdirSync(directoryPath, { recursive: true });
100 }
101
102 const images = getImages(directoryPath, sort);
103 if (order === 'desc') {
104 images.reverse();
105 }
88 return response.send(images);106 return response.send(images);
89 } catch (error) {107 } catch (error) {
90 console.error(error);108 console.error(error);
91 return response.status(500).send({ error: 'Unable to retrieve files' });109 return response.status(500).send({ error: 'Unable to retrieve files' });
92 }110 }
93});111});
112
113router.post('/folders', (request, response) => {
114 try {
115 const directoryPath = request.user.directories.userImages;
116 if (!fs.existsSync(directoryPath)) {
117 fs.mkdirSync(directoryPath, { recursive: true });
118 }
119
120 const folders = fs.readdirSync(directoryPath, { withFileTypes: true })
121 .filter(dirent => dirent.isDirectory())
122 .map(dirent => dirent.name);
123
124 return response.send(folders);
125 } catch (error) {
126 console.error(error);
127 return response.status(500).send({ error: 'Unable to retrieve folders' });
128 }
129});