mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Add gallery folder and sort order controls (#3605)
* Add gallery folder and sort order controls Closes #3601 * Refactor sort constants to use Object.freeze for immutability * Add comment * Remove excessive null propagation * Update type hint for gallery.folders * Use defaultSettings.sort as a fallback * Throw in groups * Handle rename/deletion events * Merge init functions * Fix multiple gallery file uplods * Add min-height for gallery element * Fix gallery endpoint not parsing body * translatable toasts * Pass folder path in request body * Change restore pictogram * Add title to gallery thumbnail images * Allow optional folder parameter in image list endpoint and handle deprecated usage warning * Add validation for folder parameter in image list endpoint * Add border to gallery sort selection * Remove override if default folder is set to input * Use server-side path sanitation * Sanitize gallery folder input before updating --------- Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com>
This commit is contained in:
@ -210,6 +210,12 @@ export const extension_settings = {
|
|||||||
* @type {string[]}
|
* @type {string[]}
|
||||||
*/
|
*/
|
||||||
disabled_attachments: [],
|
disabled_attachments: [],
|
||||||
|
gallery: {
|
||||||
|
/** @type {{[characterKey: string]: string}} */
|
||||||
|
folders: {},
|
||||||
|
/** @type {string} */
|
||||||
|
sort: 'dateAsc',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function showHideExtensionsMenu() {
|
function showHideExtensionsMenu() {
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
event_types,
|
event_types,
|
||||||
} from '../../../script.js';
|
} from '../../../script.js';
|
||||||
import { groups, selected_group } from '../../group-chats.js';
|
import { groups, selected_group } from '../../group-chats.js';
|
||||||
import { loadFileToDocument, delay } from '../../utils.js';
|
import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js';
|
||||||
import { loadMovingUIState } from '../../power-user.js';
|
import { loadMovingUIState } from '../../power-user.js';
|
||||||
import { dragElement } from '../../RossAscends-mods.js';
|
import { dragElement } from '../../RossAscends-mods.js';
|
||||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||||
@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
|||||||
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||||
import { DragAndDropHandler } from '../../dragdrop.js';
|
import { DragAndDropHandler } from '../../dragdrop.js';
|
||||||
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||||
import { translate } from '../../i18n.js';
|
import { t, translate } from '../../i18n.js';
|
||||||
|
|
||||||
const extensionName = 'gallery';
|
const extensionName = 'gallery';
|
||||||
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
|
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
|
||||||
@ -50,6 +50,48 @@ mutationObserver.observe(document.body, {
|
|||||||
subtree: false,
|
subtree: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SORT = Object.freeze({
|
||||||
|
NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` },
|
||||||
|
NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` },
|
||||||
|
DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` },
|
||||||
|
DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultSettings = Object.freeze({
|
||||||
|
folders: {},
|
||||||
|
sort: SORT.DATE_ASC.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the settings for the gallery extension.
|
||||||
|
*/
|
||||||
|
function initSettings() {
|
||||||
|
let shouldSave = false;
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
if (!context.extensionSettings.gallery) {
|
||||||
|
context.extensionSettings.gallery = structuredClone(defaultSettings);
|
||||||
|
shouldSave = true;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(defaultSettings)) {
|
||||||
|
if (!Object.hasOwn(context.extensionSettings.gallery, key)) {
|
||||||
|
context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]);
|
||||||
|
shouldSave = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldSave) {
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the gallery folder for a given character.
|
||||||
|
* @param {import('../../char-data.js').v1CharData} char Character data
|
||||||
|
* @returns {string} The gallery folder for the character
|
||||||
|
*/
|
||||||
|
function getGalleryFolder(char) {
|
||||||
|
return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
|
* Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
|
||||||
* to get the filenames and then constructs the item list.
|
* to get the filenames and then constructs the item list.
|
||||||
@ -58,11 +100,20 @@ mutationObserver.observe(document.body, {
|
|||||||
* @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
|
* @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
|
||||||
*/
|
*/
|
||||||
async function getGalleryItems(url) {
|
async function getGalleryItems(url) {
|
||||||
const response = await fetch(`/api/images/list/${url}`, {
|
const sortValue = getSortOrder();
|
||||||
|
const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC;
|
||||||
|
const response = await fetch('/api/images/list', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getRequestHeaders(),
|
headers: getRequestHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: url,
|
||||||
|
sortField: sortObj.field,
|
||||||
|
sortOrder: sortObj.order,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
url = await getSanitizedFilename(url);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const items = data.map((file) => ({
|
const items = data.map((file) => ({
|
||||||
src: `user/images/${url}/${file}`,
|
src: `user/images/${url}/${file}`,
|
||||||
@ -73,6 +124,46 @@ async function getGalleryItems(url) {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of gallery folders. This function calls an API endpoint
|
||||||
|
* @returns {Promise<string[]>} - Resolves with an array of gallery folders.
|
||||||
|
*/
|
||||||
|
async function getGalleryFolders() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error. Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch gallery folders:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the sort order for the gallery.
|
||||||
|
* @param {string} order Sort order
|
||||||
|
*/
|
||||||
|
function setSortOrder(order) {
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
context.extensionSettings.gallery.sort = order;
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the current sort order for the gallery.
|
||||||
|
* @returns {string} The current sort order for the gallery.
|
||||||
|
*/
|
||||||
|
function getSortOrder() {
|
||||||
|
return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
|
* Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
|
||||||
* It uses the nanogallery2 library to display the items and also initializes
|
* It uses the nanogallery2 library to display the items and also initializes
|
||||||
@ -106,11 +197,28 @@ async function initGallery(items, url) {
|
|||||||
},
|
},
|
||||||
galleryDisplayMode: 'pagination',
|
galleryDisplayMode: 'pagination',
|
||||||
fnThumbnailOpen: viewWithDragbox,
|
fnThumbnailOpen: viewWithDragbox,
|
||||||
|
fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) {
|
||||||
|
if (!item?.src) return;
|
||||||
|
$thumbnail.attr('title', String(item.src).split('/').pop());
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files, event) => {
|
const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => {
|
||||||
let file = files[0];
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
uploadFile(file, url); // Added url parameter to know where to upload
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload each file
|
||||||
|
for (const file of files) {
|
||||||
|
await uploadFile(file, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the gallery
|
||||||
|
const newItems = await getGalleryItems(url);
|
||||||
|
$('#dragGallery').closest('#gallery').remove();
|
||||||
|
await makeMovable(url);
|
||||||
|
await delay(100);
|
||||||
|
await initGallery(newItems, url);
|
||||||
});
|
});
|
||||||
|
|
||||||
const resizeHandler = function () {
|
const resizeHandler = function () {
|
||||||
@ -170,14 +278,13 @@ async function showCharGallery() {
|
|||||||
try {
|
try {
|
||||||
let url = selected_group || this_chid;
|
let url = selected_group || this_chid;
|
||||||
if (!selected_group && this_chid !== undefined) {
|
if (!selected_group && this_chid !== undefined) {
|
||||||
const char = characters[this_chid];
|
url = getGalleryFolder(characters[this_chid]);
|
||||||
url = char.name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await getGalleryItems(url);
|
const items = await getGalleryItems(url);
|
||||||
// if there already is a gallery, destroy it and place this one in its place
|
// if there already is a gallery, destroy it and place this one in its place
|
||||||
$('#dragGallery').closest('#gallery').remove();
|
$('#dragGallery').closest('#gallery').remove();
|
||||||
makeMovable();
|
await makeMovable(url);
|
||||||
await delay(100);
|
await delay(100);
|
||||||
await initGallery(items, url);
|
await initGallery(items, url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -196,92 +303,79 @@ async function showCharGallery() {
|
|||||||
* @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
|
* @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
|
||||||
*/
|
*/
|
||||||
async function uploadFile(file, url) {
|
async function uploadFile(file, url) {
|
||||||
// Convert the file to a base64 string
|
try {
|
||||||
const reader = new FileReader();
|
// Convert the file to a base64 string
|
||||||
reader.onloadend = async function () {
|
const base64Data = await getBase64Async(file);
|
||||||
const base64Data = reader.result;
|
|
||||||
|
|
||||||
// Create the payload
|
// Create the payload
|
||||||
const payload = {
|
const payload = {
|
||||||
image: base64Data,
|
image: base64Data,
|
||||||
|
ch_name: url,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the ch_name from the provided URL (assuming it's the character name)
|
const response = await fetch('/api/images/upload', {
|
||||||
payload.ch_name = url;
|
method: 'POST',
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (!response.ok) {
|
||||||
const headers = await getRequestHeaders();
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
|
||||||
// Merge headers with content-type for JSON
|
|
||||||
Object.assign(headers, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch('/api/images/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
toastr.success('File uploaded successfully. Saved at: ' + result.path);
|
|
||||||
|
|
||||||
// Refresh the gallery
|
|
||||||
const newItems = await getGalleryItems(url); // Fetch the latest items
|
|
||||||
$('#dragGallery').closest('#gallery').remove(); // Destroy old gallery
|
|
||||||
makeMovable();
|
|
||||||
await delay(100);
|
|
||||||
await initGallery(newItems, url); // Reinitialize the gallery with new items and pass 'url'
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was an issue uploading the file:', error);
|
|
||||||
|
|
||||||
// Replacing alert with toastr error notification
|
|
||||||
toastr.error('Failed to upload the file.');
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
const result = await response.json();
|
||||||
|
|
||||||
|
toastr.success(t`File uploaded successfully. Saved at: ${result.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an issue uploading the file:', error);
|
||||||
|
|
||||||
|
// Replacing alert with toastr error notification
|
||||||
|
toastr.error(t`Failed to upload the file.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
|
||||||
// Register an event listener
|
|
||||||
eventSource.on('charManagementDropdown', (selectedOptionId) => {
|
|
||||||
if (selectedOptionId === 'show_char_gallery') {
|
|
||||||
showCharGallery();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add an option to the dropdown
|
|
||||||
$('#char-management-dropdown').append(
|
|
||||||
$('<option>', {
|
|
||||||
id: 'show_char_gallery',
|
|
||||||
text: translate('Show Gallery'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new draggable container based on a template.
|
* Creates a new draggable container based on a template.
|
||||||
* This function takes a template with the ID 'generic_draggable_template' and clones it.
|
* This function takes a template with the ID 'generic_draggable_template' and clones it.
|
||||||
* The cloned element has its attributes set, a new child div appended, and is made visible on the body.
|
* The cloned element has its attributes set, a new child div appended, and is made visible on the body.
|
||||||
* Additionally, it sets up the element to prevent dragging on its images.
|
* Additionally, it sets up the element to prevent dragging on its images.
|
||||||
|
* @param {string} url - The URL of the image source.
|
||||||
|
* @returns {Promise<void>} - Promise representing the completion of the draggable container creation.
|
||||||
*/
|
*/
|
||||||
function makeMovable(id = 'gallery') {
|
async function makeMovable(url) {
|
||||||
|
|
||||||
console.debug('making new container from template');
|
console.debug('making new container from template');
|
||||||
|
const id = 'gallery';
|
||||||
const template = $('#generic_draggable_template').html();
|
const template = $('#generic_draggable_template').html();
|
||||||
const newElement = $(template);
|
const newElement = $(template);
|
||||||
newElement.css('background-color', 'var(--SmartThemeBlurTintColor)');
|
newElement.css('background-color', 'var(--SmartThemeBlurTintColor)');
|
||||||
newElement.attr('forChar', id);
|
newElement.attr('forChar', id);
|
||||||
newElement.attr('id', id);
|
newElement.attr('id', id);
|
||||||
newElement.find('.drag-grabber').attr('id', `${id}header`);
|
newElement.find('.drag-grabber').attr('id', `${id}header`);
|
||||||
newElement.find('.dragTitle').text('Image Gallery');
|
const dragTitle = newElement.find('.dragTitle');
|
||||||
//add a div for the gallery
|
dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline');
|
||||||
newElement.append('<div id="dragGallery"></div>');
|
const titleText = document.createElement('span');
|
||||||
|
titleText.textContent = t`Image Gallery`;
|
||||||
|
dragTitle.append(titleText);
|
||||||
|
const sortSelect = document.createElement('select');
|
||||||
|
sortSelect.classList.add('gallery-sort-select');
|
||||||
|
|
||||||
|
for (const sort of Object.values(SORT)) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = sort.value;
|
||||||
|
option.textContent = sort.label;
|
||||||
|
sortSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortSelect.addEventListener('change', async () => {
|
||||||
|
const selectedOption = sortSelect.options[sortSelect.selectedIndex].value;
|
||||||
|
setSortOrder(selectedOption);
|
||||||
|
closeButton.trigger('click');
|
||||||
|
await showCharGallery();
|
||||||
|
});
|
||||||
|
|
||||||
|
sortSelect.value = getSortOrder();
|
||||||
|
dragTitle.append(sortSelect);
|
||||||
|
|
||||||
// add no-scrollbar class to this element
|
// add no-scrollbar class to this element
|
||||||
newElement.addClass('no-scrollbar');
|
newElement.addClass('no-scrollbar');
|
||||||
|
|
||||||
@ -290,6 +384,81 @@ function makeMovable(id = 'gallery') {
|
|||||||
closeButton.attr('id', `${id}close`);
|
closeButton.attr('id', `${id}close`);
|
||||||
closeButton.attr('data-related-id', `${id}`);
|
closeButton.attr('data-related-id', `${id}`);
|
||||||
|
|
||||||
|
const topBarElement = document.createElement('div');
|
||||||
|
topBarElement.classList.add('flex-container', 'alignItemsCenter');
|
||||||
|
|
||||||
|
const onChangeFolder = async (/** @type {Event} */ e) => {
|
||||||
|
if (e instanceof KeyboardEvent && e.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newUrl = await getSanitizedFilename(galleryFolderInput.value);
|
||||||
|
updateGalleryFolder(newUrl);
|
||||||
|
closeButton.trigger('click');
|
||||||
|
await showCharGallery();
|
||||||
|
toastr.info(t`Gallery folder changed to ${newUrl}`);
|
||||||
|
galleryFolderInput.value = newUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change gallery folder:', error);
|
||||||
|
toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRestoreFolder = async () => {
|
||||||
|
try {
|
||||||
|
restoreGalleryFolder();
|
||||||
|
closeButton.trigger('click');
|
||||||
|
await showCharGallery();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore gallery folder:', error);
|
||||||
|
toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const galleryFolderInput = document.createElement('input');
|
||||||
|
galleryFolderInput.type = 'text';
|
||||||
|
galleryFolderInput.placeholder = t`Folder Name`;
|
||||||
|
galleryFolderInput.title = t`Enter a folder name to change the gallery folder`;
|
||||||
|
galleryFolderInput.value = url;
|
||||||
|
galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1');
|
||||||
|
galleryFolderInput.addEventListener('keyup', onChangeFolder);
|
||||||
|
|
||||||
|
const galleryFolderAccept = document.createElement('div');
|
||||||
|
galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw');
|
||||||
|
galleryFolderAccept.title = t`Change gallery folder`;
|
||||||
|
galleryFolderAccept.addEventListener('click', onChangeFolder);
|
||||||
|
|
||||||
|
const galleryFolderRestore = document.createElement('div');
|
||||||
|
galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw');
|
||||||
|
galleryFolderRestore.title = t`Restore gallery folder`;
|
||||||
|
galleryFolderRestore.addEventListener('click', onRestoreFolder);
|
||||||
|
|
||||||
|
topBarElement.appendChild(galleryFolderInput);
|
||||||
|
topBarElement.appendChild(galleryFolderAccept);
|
||||||
|
topBarElement.appendChild(galleryFolderRestore);
|
||||||
|
newElement.append(topBarElement);
|
||||||
|
|
||||||
|
// Populate the gallery folder input with a list of available folders
|
||||||
|
const folders = await getGalleryFolders();
|
||||||
|
$(galleryFolderInput)
|
||||||
|
.autocomplete({
|
||||||
|
source: (i, o) => {
|
||||||
|
const term = i.term.toLowerCase();
|
||||||
|
const filtered = folders.filter(f => f.toLowerCase().includes(term));
|
||||||
|
o(filtered);
|
||||||
|
},
|
||||||
|
select: (e, u) => {
|
||||||
|
galleryFolderInput.value = u.item.value;
|
||||||
|
onChangeFolder(e);
|
||||||
|
},
|
||||||
|
minLength: 0,
|
||||||
|
})
|
||||||
|
.on('focus', () => $(galleryFolderInput).autocomplete('search', ''));
|
||||||
|
|
||||||
|
//add a div for the gallery
|
||||||
|
newElement.append('<div id="dragGallery"></div>');
|
||||||
|
|
||||||
$('#dragGallery').css('display', 'block');
|
$('#dragGallery').css('display', 'block');
|
||||||
|
|
||||||
$('#movingDivs').append(newElement);
|
$('#movingDivs').append(newElement);
|
||||||
@ -305,6 +474,59 @@ function makeMovable(id = 'gallery') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the gallery folder to a new URL.
|
||||||
|
* @param {string} newUrl - The new URL to set for the gallery folder.
|
||||||
|
*/
|
||||||
|
function updateGalleryFolder(newUrl) {
|
||||||
|
if (!newUrl) {
|
||||||
|
throw new Error('Folder name cannot be empty');
|
||||||
|
}
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
if (context.groupId) {
|
||||||
|
throw new Error('Cannot change gallery folder in group chat');
|
||||||
|
}
|
||||||
|
if (context.characterId === undefined) {
|
||||||
|
throw new Error('Character is not selected');
|
||||||
|
}
|
||||||
|
const avatar = context.characters[context.characterId]?.avatar;
|
||||||
|
const name = context.characters[context.characterId]?.name;
|
||||||
|
if (!avatar) {
|
||||||
|
throw new Error('Character PNG ID is not found');
|
||||||
|
}
|
||||||
|
if (newUrl === name) {
|
||||||
|
// Default folder name is picked, remove the override
|
||||||
|
delete context.extensionSettings.gallery.folders[avatar];
|
||||||
|
} else {
|
||||||
|
// Custom folder name is provided, set the override
|
||||||
|
context.extensionSettings.gallery.folders[avatar] = newUrl;
|
||||||
|
}
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the gallery folder to the default value.
|
||||||
|
*/
|
||||||
|
function restoreGalleryFolder() {
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
if (context.groupId) {
|
||||||
|
throw new Error('Cannot change gallery folder in group chat');
|
||||||
|
}
|
||||||
|
if (context.characterId === undefined) {
|
||||||
|
throw new Error('Character is not selected');
|
||||||
|
}
|
||||||
|
const avatar = context.characters[context.characterId]?.avatar;
|
||||||
|
if (!avatar) {
|
||||||
|
throw new Error('Character PNG ID is not found');
|
||||||
|
}
|
||||||
|
const existingOverride = context.extensionSettings.gallery.folders[avatar];
|
||||||
|
if (!existingOverride) {
|
||||||
|
throw new Error('No folder override found');
|
||||||
|
}
|
||||||
|
delete context.extensionSettings.gallery.folders[avatar];
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new draggable image based on a template.
|
* Creates a new draggable image based on a template.
|
||||||
*
|
*
|
||||||
@ -331,7 +553,7 @@ function makeDragImg(id, url) {
|
|||||||
const imgElem = document.createElement('img');
|
const imgElem = document.createElement('img');
|
||||||
imgElem.src = url;
|
imgElem.src = url;
|
||||||
let uniqueId = `draggable_${id}`;
|
let uniqueId = `draggable_${id}`;
|
||||||
const draggableElem = newElement.querySelector('.draggable');
|
const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
|
||||||
if (draggableElem) {
|
if (draggableElem) {
|
||||||
draggableElem.appendChild(imgElem);
|
draggableElem.appendChild(imgElem);
|
||||||
|
|
||||||
@ -351,7 +573,7 @@ function makeDragImg(id, url) {
|
|||||||
|
|
||||||
// Add an id to the close button
|
// Add an id to the close button
|
||||||
// If the close button exists, set related-id
|
// If the close button exists, set related-id
|
||||||
const closeButton = draggableElem.querySelector('.dragClose');
|
const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose'));
|
||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.id = `${uniqueId}close`;
|
closeButton.id = `${uniqueId}close`;
|
||||||
closeButton.dataset.relatedId = uniqueId;
|
closeButton.dataset.relatedId = uniqueId;
|
||||||
@ -456,8 +678,7 @@ async function listGalleryCommand(args) {
|
|||||||
try {
|
try {
|
||||||
let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
|
let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
|
||||||
if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
|
if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
|
||||||
const char = characters[this_chid];
|
url = getGalleryFolder(characters[this_chid]);
|
||||||
url = char.name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await getGalleryItems(url);
|
const items = await getGalleryItems(url);
|
||||||
@ -469,3 +690,37 @@ async function listGalleryCommand(args) {
|
|||||||
}
|
}
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On extension load, ensure the settings are initialized
|
||||||
|
(function () {
|
||||||
|
initSettings();
|
||||||
|
eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar];
|
||||||
|
if (galleryFolder) {
|
||||||
|
context.extensionSettings.gallery.folders[newAvatar] = galleryFolder;
|
||||||
|
delete context.extensionSettings.gallery.folders[oldAvatar];
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
eventSource.on(event_types.CHARACTER_DELETED, (data) => {
|
||||||
|
const avatar = data?.character?.avatar;
|
||||||
|
if (!avatar) return;
|
||||||
|
const context = SillyTavern.getContext();
|
||||||
|
delete context.extensionSettings.gallery.folders[avatar];
|
||||||
|
context.saveSettingsDebounced();
|
||||||
|
});
|
||||||
|
eventSource.on('charManagementDropdown', (selectedOptionId) => {
|
||||||
|
if (selectedOptionId === 'show_char_gallery') {
|
||||||
|
showCharGallery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add an option to the dropdown
|
||||||
|
$('#char-management-dropdown').append(
|
||||||
|
$('<option>', {
|
||||||
|
id: 'show_char_gallery',
|
||||||
|
text: translate('Show Gallery'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
@ -3,3 +3,43 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-folder-input {
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: calc(var(--mainFontSize)* 0.9);
|
||||||
|
opacity: 0.8;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-folder-input:placeholder-shown {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.5;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-sort-select {
|
||||||
|
width: max-content;
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow-x: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.8;
|
||||||
|
background: none;
|
||||||
|
background-image: url(/img/down-arrow.svg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 6px center;
|
||||||
|
background-size: 8px 5px;
|
||||||
|
padding-right: 20px;
|
||||||
|
font-size: calc(var(--mainFontSize)* 0.9);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gallery .dragTitle {
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dragGallery {
|
||||||
|
min-height: 25dvh;
|
||||||
|
}
|
||||||
|
@ -76,18 +76,54 @@ router.post('/upload', jsonParser, async (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/list/:folder', (request, response) => {
|
router.post('/list/:folder?', jsonParser, (request, response) => {
|
||||||
const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder));
|
|
||||||
|
|
||||||
if (!fs.existsSync(directoryPath)) {
|
|
||||||
fs.mkdirSync(directoryPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const images = getImages(directoryPath, 'date');
|
if (request.params.folder) {
|
||||||
|
if (request.body.folder) {
|
||||||
|
return response.status(400).send({ error: 'Folder specified in both URL and body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Deprecated: Use POST /api/images/list with folder in request body');
|
||||||
|
request.body.folder = request.params.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.body.folder) {
|
||||||
|
return response.status(400).send({ error: 'No folder specified' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));
|
||||||
|
const sort = request.body.sortField || 'date';
|
||||||
|
const order = request.body.sortOrder || 'asc';
|
||||||
|
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
fs.mkdirSync(directoryPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = getImages(directoryPath, sort);
|
||||||
|
if (order === 'desc') {
|
||||||
|
images.reverse();
|
||||||
|
}
|
||||||
return response.send(images);
|
return response.send(images);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return response.status(500).send({ error: 'Unable to retrieve files' });
|
return response.status(500).send({ error: 'Unable to retrieve files' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/folders', (request, response) => {
|
||||||
|
try {
|
||||||
|
const directoryPath = request.user.directories.userImages;
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
fs.mkdirSync(directoryPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = fs.readdirSync(directoryPath, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => dirent.name);
|
||||||
|
|
||||||
|
return response.send(folders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return response.status(500).send({ error: 'Unable to retrieve folders' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Reference in New Issue
Block a user