mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic POST /api/lm/browse-directory endpoint (browse logic extracted from the recipe batch-import handler into py/utils/directory_browser.py) and wire a browse button plus advisory validate-path feedback (POST /api/lm/validate-path) into the settings path inputs: recipes path, example images path/local root, and the extra-folder/model-path rows. The browse button insets into the right edge of static inputs so narrow settings rows keep their single-control layout. Translations for the new settings.directoryPicker and settings.pathValidation keys are filled in for all 9 locales.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/* Directory Picker Modal */
|
||||
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
|
||||
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
|
||||
#directoryPickerModal {
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.directory-picker-content {
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.directory-picker-content h3 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Manual path row */
|
||||
#directoryPickerModal .directory-picker-path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-path-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-path-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||
}
|
||||
|
||||
/* Directory browser (class names shared with the batch import browser) */
|
||||
#directoryPickerModal .directory-browser {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .back-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#directoryPickerModal .current-path {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item:hover {
|
||||
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#directoryPickerModal .folder-item i {
|
||||
color: #fbbf24;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#directoryPickerModal .item-name {
|
||||
flex: 1;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#directoryPickerModal .browser-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(from var(--lora-error) l c h / 0.12);
|
||||
color: var(--lora-error);
|
||||
font-size: 0.85em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#directoryPickerModal .directory-picker-empty {
|
||||
padding: var(--space-2);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] #directoryPickerModal .directory-browser {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #directoryPickerModal .browser-header,
|
||||
[data-theme="dark"] #directoryPickerModal .browser-footer {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #directoryPickerModal .folder-item i {
|
||||
color: #fcd34d;
|
||||
}
|
||||
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Browse (directory picker) button — boxed accent style used on the dynamic
|
||||
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
|
||||
path fields use the .inset variant below instead. */
|
||||
#settingsModal .browse-path-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--lora-accent);
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#settingsModal .browse-path-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Inset variant (static path fields): the button floats inside the right
|
||||
edge of the input, so the setting row keeps its single-control look and
|
||||
narrow columns never push it onto a second line. */
|
||||
#settingsModal .browse-path-btn.inset {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#settingsModal .browse-path-btn.inset:hover {
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#settingsModal input.has-inset-browse {
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
/* Advisory path validation feedback (wraps below the input row) */
|
||||
#settingsModal .text-input-wrapper,
|
||||
#settingsModal .path-control {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#settingsModal .path-control > .text-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-validation {
|
||||
display: none;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.path-validation.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.path-validation.valid {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
/* Highlight animation for setting items targeted from Doctor actions */
|
||||
@keyframes settings-highlight-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@import 'components/modal/example-access-modal.css';
|
||||
@import 'components/modal/support-modal.css';
|
||||
@import 'components/modal/download-modal.css';
|
||||
@import 'components/modal/directory-picker-modal.css';
|
||||
@import 'components/toast.css';
|
||||
@import 'components/loading.css';
|
||||
@import 'components/menu.css';
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
|
||||
* Self-managed (NOT registered with ModalManager): it stacks above the
|
||||
* settings modal, so ModalManager's "close current modal on open" behavior
|
||||
* would kill the modal underneath.
|
||||
*/
|
||||
class DirectoryPickerModal {
|
||||
constructor() {
|
||||
this.isOpen = false;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this.onSelect = null;
|
||||
this.elements = {};
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
open({ initialPath = '', onSelect } = {}) {
|
||||
this._cacheElements();
|
||||
if (!this.elements.modal) {
|
||||
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
this._unbindEvents();
|
||||
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this._clearError();
|
||||
this.elements.folderList.innerHTML = '';
|
||||
this.elements.currentPathEl.textContent = '';
|
||||
this.elements.upBtn.disabled = true;
|
||||
this.elements.pathInput.value = initialPath || '';
|
||||
|
||||
this._bindEvents();
|
||||
document.body.classList.add('modal-open');
|
||||
this.elements.modal.style.display = 'block';
|
||||
this.isOpen = true;
|
||||
|
||||
// An empty path lets the server pick its default (user home).
|
||||
this.loadDirectory(initialPath || '');
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.isOpen = false;
|
||||
this._unbindEvents();
|
||||
if (this.elements.modal) {
|
||||
this.elements.modal.style.display = 'none';
|
||||
}
|
||||
this.onSelect = null;
|
||||
// Keep body.modal-open: the settings modal underneath may still be open.
|
||||
}
|
||||
|
||||
async loadDirectory(path) {
|
||||
try {
|
||||
const response = await fetch('/api/lm/browse-directory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this._clearError();
|
||||
this._renderDirectory(data);
|
||||
} else {
|
||||
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
}
|
||||
|
||||
_cacheElements() {
|
||||
const modal = document.getElementById('directoryPickerModal');
|
||||
this.elements = {
|
||||
modal,
|
||||
closeBtn: document.getElementById('directoryPickerCloseBtn'),
|
||||
pathInput: document.getElementById('directoryPickerPathInput'),
|
||||
goBtn: document.getElementById('directoryPickerGoBtn'),
|
||||
upBtn: document.getElementById('directoryPickerUpBtn'),
|
||||
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
|
||||
folderList: document.getElementById('directoryPickerFolderList'),
|
||||
errorEl: document.getElementById('directoryPickerError'),
|
||||
selectBtn: document.getElementById('directoryPickerSelectBtn')
|
||||
};
|
||||
}
|
||||
|
||||
_bind(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._bindings.push([target, type, handler, options]);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
|
||||
|
||||
this._bind(closeBtn, 'click', () => this.close());
|
||||
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
|
||||
this._bind(pathInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.loadDirectory(pathInput.value.trim());
|
||||
}
|
||||
});
|
||||
this._bind(upBtn, 'click', () => {
|
||||
// Server-provided parent_path: Windows paths cannot be derived client-side.
|
||||
if (this.parentPath) {
|
||||
this.loadDirectory(this.parentPath);
|
||||
}
|
||||
});
|
||||
this._bind(selectBtn, 'click', () => this._selectCurrent());
|
||||
|
||||
// Capture phase + stopPropagation so an ESC here never reaches the
|
||||
// settings modal's own ESC handler underneath.
|
||||
this._bind(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Backdrop click (the .modal element itself, not its content).
|
||||
this._bind(modal, 'click', (event) => {
|
||||
if (event.target === modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_unbindEvents() {
|
||||
for (const [target, type, handler, options] of this._bindings) {
|
||||
target.removeEventListener(type, handler, options);
|
||||
}
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
_renderDirectory(data) {
|
||||
this.currentPath = data.current_path || '';
|
||||
this.parentPath = data.parent_path || null;
|
||||
|
||||
this.elements.currentPathEl.textContent = this.currentPath;
|
||||
this.elements.pathInput.value = this.currentPath;
|
||||
this.elements.upBtn.disabled = !this.parentPath;
|
||||
|
||||
const folderList = this.elements.folderList;
|
||||
folderList.innerHTML = '';
|
||||
|
||||
const directories = data.directories || [];
|
||||
if (directories.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'directory-picker-empty';
|
||||
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
|
||||
folderList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
directories.forEach((entry) => {
|
||||
folderList.appendChild(this._createFolderItem(entry));
|
||||
});
|
||||
}
|
||||
|
||||
// Each entry is { name, path, is_parent }; the server supplies the full
|
||||
// child path, so navigation never joins path segments client-side.
|
||||
_createFolderItem(entry) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-folder"></i>
|
||||
<span class="item-name">${this._escapeHtml(entry.name)}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
this.loadDirectory(entry.path);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
_selectCurrent() {
|
||||
if (!this.currentPath) return;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(this.currentPath);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
this.elements.errorEl.textContent = message;
|
||||
this.elements.errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
this.elements.errorEl.textContent = '';
|
||||
this.elements.errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
export const directoryPickerModal = new DirectoryPickerModal();
|
||||
export { DirectoryPickerModal };
|
||||
@@ -15,9 +15,17 @@ import { i18n } from '../i18n/index.js';
|
||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
|
||||
|
||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||
|
||||
const PATH_VALIDATION_ERROR_I18N = {
|
||||
path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' },
|
||||
not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' },
|
||||
not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' },
|
||||
not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' },
|
||||
};
|
||||
|
||||
// Other-model sub_type -> i18n label key, mirroring the checkbox list in
|
||||
// templates/components/modals/settings/library.html.
|
||||
const OTHER_SUB_TYPE_LABEL_KEYS = {
|
||||
@@ -1198,6 +1206,24 @@ export class SettingsManager {
|
||||
if (useNewLicenseIconsCheckbox) {
|
||||
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
||||
}
|
||||
|
||||
// Directory browse buttons + advisory path validation (idempotent,
|
||||
// safe to call on every modal open).
|
||||
this.attachPathField('recipesPath', {
|
||||
onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'),
|
||||
});
|
||||
this.attachPathField('exampleImagesPath', {
|
||||
onAfterSelect: (pickedPath) => {
|
||||
// Mirror ExampleImagesManager's blur-save flow.
|
||||
window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== '');
|
||||
this.saveSetting('example_images_path', pickedPath)
|
||||
.then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success'))
|
||||
.catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error'));
|
||||
},
|
||||
});
|
||||
this.attachPathField('exampleImagesLocalRoot', {
|
||||
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
|
||||
});
|
||||
}
|
||||
|
||||
loadDownloadBackendSettings() {
|
||||
@@ -1806,6 +1832,11 @@ export class SettingsManager {
|
||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button type="button" class="browse-path-btn"
|
||||
onclick="settingsManager.browseForPathRow(this, '${modelType}')"
|
||||
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</button>
|
||||
<button type="button" class="remove-path-btn"
|
||||
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
@@ -2242,6 +2273,11 @@ export class SettingsManager {
|
||||
onblur="settingsManager.updateModelFolderPaths('${key}')"
|
||||
onfocus="settingsManager.clearModelFolderPathError(this)"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button type="button" class="browse-path-btn"
|
||||
onclick="settingsManager.browseForPathRow(this, '${key}', true)"
|
||||
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</button>
|
||||
<button type="button" class="remove-path-btn"
|
||||
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
|
||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
@@ -3763,6 +3799,161 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Directory picker + live path validation ─────────────────────────
|
||||
// Validation is advisory only: it never blocks or alters save flows.
|
||||
|
||||
attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) {
|
||||
console.warn(`SettingsManager.attachPathField: #${inputId} not found`);
|
||||
return;
|
||||
}
|
||||
if (input.dataset.pathFieldAttached === '1') return;
|
||||
input.dataset.pathFieldAttached = '1';
|
||||
|
||||
const browseBtn = document.createElement('button');
|
||||
browseBtn.type = 'button';
|
||||
browseBtn.className = 'browse-path-btn inset';
|
||||
browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders');
|
||||
browseBtn.innerHTML = '<i class="fas fa-folder-open"></i>';
|
||||
|
||||
// Inset layout: the button is absolutely positioned inside the right
|
||||
// edge of the input, so the row keeps its original single-control look.
|
||||
const parent = input.parentElement;
|
||||
let wrapper;
|
||||
let statusHost;
|
||||
if (parent && parent.classList.contains('path-control')) {
|
||||
// e.g. #exampleImagesPath sits beside a Download button: wrap only
|
||||
// the input so the button insets into it and Download stays beside it.
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.className = 'text-input-wrapper';
|
||||
parent.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
statusHost = parent;
|
||||
} else {
|
||||
// .text-input-wrapper provided by the setting_input macro
|
||||
wrapper = parent;
|
||||
statusHost = parent;
|
||||
}
|
||||
wrapper.appendChild(browseBtn);
|
||||
input.classList.add('has-inset-browse');
|
||||
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.className = 'path-validation';
|
||||
statusHost.appendChild(statusEl);
|
||||
|
||||
input._pathFieldConfig = { expect, onAfterSelect, statusEl };
|
||||
|
||||
browseBtn.addEventListener('click', () => this.browseForPath(inputId));
|
||||
input.addEventListener('blur', () => {
|
||||
clearTimeout(input._pathValidationTimer);
|
||||
this.validatePath(input, statusEl, expect);
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(input._pathValidationTimer);
|
||||
input._pathValidationTimer = setTimeout(() => {
|
||||
this.validatePath(input, statusEl, expect);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
async validatePath(input, statusEl, expect = 'directory') {
|
||||
const value = input.value.trim();
|
||||
const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1;
|
||||
|
||||
if (!value) {
|
||||
this._clearPathStatus(statusEl);
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch('/api/lm/validate-path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: value, expect }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
if (seq === input._pathValidationSeq) {
|
||||
this._clearPathStatus(statusEl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore responses overtaken by newer input or validation runs.
|
||||
if (seq !== input._pathValidationSeq || input.value.trim() !== value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success && !data.error_code) {
|
||||
statusEl.innerHTML = `<i class="fas fa-check-circle"></i><span>${translate('settings.pathValidation.valid', {}, 'Path is valid')}</span>`;
|
||||
statusEl.classList.add('visible', 'valid');
|
||||
} else {
|
||||
const message = this._getPathValidationMessage(data);
|
||||
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i><span></span>';
|
||||
statusEl.querySelector('span').textContent = message;
|
||||
statusEl.classList.add('visible');
|
||||
statusEl.classList.remove('valid');
|
||||
}
|
||||
}
|
||||
|
||||
_getPathValidationMessage(data) {
|
||||
const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code];
|
||||
if (entry) {
|
||||
return translate(entry.key, {}, entry.fallback);
|
||||
}
|
||||
return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist');
|
||||
}
|
||||
|
||||
_clearPathStatus(statusEl) {
|
||||
statusEl.classList.remove('visible', 'valid');
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
|
||||
browseForPath(inputId, { onAfterSelect } = {}) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
const config = input._pathFieldConfig || {};
|
||||
const afterSelect = onAfterSelect || config.onAfterSelect;
|
||||
|
||||
directoryPickerModal.open({
|
||||
initialPath: input.value.trim(),
|
||||
onSelect: (pickedPath) => {
|
||||
input.value = pickedPath;
|
||||
if (config.statusEl) {
|
||||
this.validatePath(input, config.statusEl, config.expect || 'directory');
|
||||
}
|
||||
if (typeof afterSelect === 'function') {
|
||||
afterSelect(pickedPath);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Browse variant for the dynamic extra/model folder path rows: picking a
|
||||
// folder routes through the row's existing validation + save logic.
|
||||
browseForPathRow(btn, key, isModelPath = false) {
|
||||
const row = btn.closest('.extra-folder-path-row');
|
||||
const input = row ? row.querySelector('.extra-folder-path-input') : null;
|
||||
if (!input) return;
|
||||
|
||||
directoryPickerModal.open({
|
||||
initialPath: input.value.trim(),
|
||||
onSelect: (pickedPath) => {
|
||||
input.value = pickedPath;
|
||||
if (isModelPath) {
|
||||
this.updateModelFolderPaths(key);
|
||||
} else {
|
||||
this.updateExtraFolderPaths(key);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async saveInputSetting(elementId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
Reference in New Issue
Block a user