mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-22 03:20:54 -03:00
Compare commits
2 Commits
faf64f8986
...
07f49559be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f49559be | ||
|
|
b24b1a7e57 |
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
|
||||
@@ -1328,6 +1328,9 @@ class SettingsHandler:
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (civitai_api_key_set).
|
||||
"civitai_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1382,6 +1385,9 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
|
||||
@@ -134,6 +134,9 @@ class SettingsManager:
|
||||
self._template_path = (
|
||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
)
|
||||
# Known placeholder value in settings.json.example; any file containing
|
||||
# this value should be treated as "not configured".
|
||||
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -165,6 +168,12 @@ class SettingsManager:
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
if self._matches_template_payload(data):
|
||||
self._preserve_disk_template = True
|
||||
# Clean up the template placeholder so it is not treated
|
||||
# as a real key (affects both the frontend boolean and
|
||||
# the downloader's Authorization header).
|
||||
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||
if data.get("civitai_api_key") == placeholder:
|
||||
data["civitai_api_key"] = ""
|
||||
return data
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
|
||||
@@ -335,7 +335,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* API key input specific styles */
|
||||
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||
.api-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* API key input specific styles (shared with proxy password) */
|
||||
.api-key-input {
|
||||
width: 100%; /* Take full width of parent */
|
||||
position: relative;
|
||||
@@ -345,7 +350,7 @@
|
||||
|
||||
.api-key-input input {
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
||||
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -353,6 +358,13 @@
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility {
|
||||
@@ -364,12 +376,98 @@
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||
.api-key-item .setting-control {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* API key status display (shown when not editing) */
|
||||
.api-key-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-status.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Status color modifiers — replace inline styles */
|
||||
.api-key-status--configured .fa-check-circle {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.api-key-status--unconfigured .fa-times-circle {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Utility classes for status icon colors (used by JS) */
|
||||
.text-success {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* API key inline edit container — flex row with input + buttons */
|
||||
.api-key-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-edit.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .api-key-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .primary-btn,
|
||||
.api-key-edit .secondary-btn {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text input wrapper styles for consistent input styling */
|
||||
.text-input-wrapper {
|
||||
width: 100%;
|
||||
|
||||
@@ -133,6 +133,16 @@ export class BaseModelApiClient {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = pageState.currentPage + 1;
|
||||
|
||||
// When resetting to page 1, scroll back to the top
|
||||
// This covers: folder selection, filter/sort/search changes,
|
||||
// favorites/update/excluded view toggles, alphabet filter, etc.
|
||||
if (resetPage) {
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFolders) {
|
||||
sidebarManager.refresh();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
@@ -457,20 +457,68 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
@@ -530,20 +578,68 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
@@ -1346,7 +1442,7 @@ export class SidebarManager {
|
||||
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||
|
||||
// Reload models with new filter
|
||||
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
||||
await this.pageControls.resetAndReload();
|
||||
}
|
||||
|
||||
|
||||
@@ -327,11 +327,16 @@ export class DoctorManager {
|
||||
case 'open-settings':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
// Open the API key editor directly
|
||||
if (typeof settingsManager.editApiKey === 'function') {
|
||||
settingsManager.editApiKey();
|
||||
} else {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'open-settings-syntax-format':
|
||||
|
||||
@@ -321,29 +321,94 @@ class MoveManager {
|
||||
}
|
||||
|
||||
try {
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
|
||||
// Deselect moving items
|
||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||
} else {
|
||||
// Single move mode
|
||||
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.currentFilePath,
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(this.currentFilePath);
|
||||
}
|
||||
|
||||
// Refresh UI by reloading the current page, same as drag-and-drop behavior
|
||||
// This ensures all metadata (like preview URLs) are correctly formatted by the backend
|
||||
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.pageControls.resetAndReload(true);
|
||||
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.lastPageControls.resetAndReload(true);
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
// Get current page state for folder filter check
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
// Determine which items are still visible after the move
|
||||
const pathsToRemove = [];
|
||||
const pathsToUpdate = []; // { originalPath, newData }
|
||||
|
||||
for (const moved of movedFiles) {
|
||||
if (!moved.original_file_path) continue;
|
||||
|
||||
if (isFolderFiltered) {
|
||||
// Compute relative folder of the new path
|
||||
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
|
||||
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Check if the new location is still within the active folder
|
||||
let stillVisible;
|
||||
if (isRecursive) {
|
||||
stillVisible = normalizedActive === '' ||
|
||||
normalizedNewFolder === normalizedActive ||
|
||||
normalizedNewFolder.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
stillVisible = normalizedNewFolder === normalizedActive;
|
||||
}
|
||||
|
||||
// Refresh folder tree in sidebar
|
||||
if (stillVisible) {
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: newRelativeFolder
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pathsToRemove.push(moved.original_file_path);
|
||||
}
|
||||
} else {
|
||||
// No folder filter active — items remain visible, just update path
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: this._getRelativeFolder(moved.new_file_path)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updates to the VirtualScroller
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
for (const update of pathsToUpdate) {
|
||||
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh folder tree in sidebar (no model data reload)
|
||||
await sidebarManager.refresh();
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
@@ -347,9 +347,9 @@ export class SettingsManager {
|
||||
if (this.isOpen) {
|
||||
this.loadSettingsToUI();
|
||||
} else {
|
||||
// Clear sensitive fields on close to prevent browser save-password prompts
|
||||
const apiKeyInput = document.getElementById('civitaiApiKey');
|
||||
if (apiKeyInput) apiKeyInput.value = '';
|
||||
// Reset API key edit mode on close
|
||||
this.cancelEditApiKey(true);
|
||||
// Clear proxy password on close
|
||||
const proxyPasswordInput = document.getElementById('proxyPassword');
|
||||
if (proxyPasswordInput) proxyPasswordInput.value = '';
|
||||
}
|
||||
@@ -825,10 +825,8 @@ export class SettingsManager {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
}
|
||||
|
||||
const civitaiApiKeyInput = document.getElementById('civitaiApiKey');
|
||||
if (civitaiApiKeyInput) {
|
||||
civitaiApiKeyInput.value = state.global.settings.civitai_api_key || '';
|
||||
}
|
||||
// Update API key status display (do NOT pre-fill the input)
|
||||
this.updateApiKeyStatus();
|
||||
|
||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||
if (civitaiHostSelect) {
|
||||
@@ -2898,16 +2896,97 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CivitAI API Key management ──────────────────────────────
|
||||
|
||||
updateApiKeyStatus() {
|
||||
const hasKey = !!(state.global.settings.civitai_api_key_set ||
|
||||
state.global.settings.civitai_api_key);
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
const statusText = document.getElementById('civitaiApiKeyStatusText');
|
||||
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
|
||||
if (!statusText || !actionBtn) return;
|
||||
|
||||
if (hasKey) {
|
||||
statusText.classList.remove('api-key-status--unconfigured');
|
||||
statusText.classList.add('api-key-status--configured');
|
||||
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
|
||||
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||
} else {
|
||||
statusText.classList.remove('api-key-status--configured');
|
||||
statusText.classList.add('api-key-status--unconfigured');
|
||||
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
|
||||
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
|
||||
}
|
||||
}
|
||||
|
||||
editApiKey() {
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusEl) statusEl.classList.add('is-hidden');
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.remove('is-hidden');
|
||||
// Focus the input
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.value = ''; // Never pre-fill the secret
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
cancelEditApiKey(silent) {
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.add('is-hidden');
|
||||
const statusContainer = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
||||
// Clear any typed value
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) input.value = '';
|
||||
if (!silent) {
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async saveApiKey() {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value.trim();
|
||||
|
||||
try {
|
||||
await this.saveSetting('civitai_api_key', value);
|
||||
showToast('toast.settings.settingsUpdated',
|
||||
{ setting: 'CivitAI API Key' }, 'success');
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed',
|
||||
{ message: error.message }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the in-memory flag so the UI reflects the change
|
||||
state.global.settings.civitai_api_key_set = !!value;
|
||||
this.cancelEditApiKey(true);
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
|
||||
toggleInputVisibility(button) {
|
||||
const input = button.parentElement.querySelector('input');
|
||||
if (!input) return;
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (input.type === 'password') {
|
||||
if (input.dataset.mask === 'css') {
|
||||
// CSS-masked input (CivitAI API key) — toggle class, not type
|
||||
input.classList.toggle('api-key-masked');
|
||||
if (icon) {
|
||||
icon.className = input.classList.contains('api-key-masked')
|
||||
? 'fas fa-eye'
|
||||
: 'fas fa-eye-slash';
|
||||
}
|
||||
} else if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
if (icon) icon.className = 'fas fa-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'fas fa-eye';
|
||||
if (icon) icon.className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
|
||||
@@ -931,6 +931,38 @@ export class VirtualScroller {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple items by their file paths.
|
||||
* More efficient than calling removeItemByFilePath individually.
|
||||
* @param {string[]} filePaths - Array of file paths to remove
|
||||
* @returns {boolean} - True if any items were removed
|
||||
*/
|
||||
removeMultipleItemsByFilePath(filePaths) {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
|
||||
|
||||
// Build a set for fast lookup
|
||||
const pathsToRemove = new Set(filePaths);
|
||||
const originalLength = this.items.length;
|
||||
|
||||
// Filter out removed items; keep those not in the set
|
||||
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
|
||||
|
||||
const removedCount = originalLength - this.items.length;
|
||||
if (removedCount === 0) return false;
|
||||
|
||||
this.totalItems = Math.max(0, this.totalItems - removedCount);
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render to fill gaps left by removed items
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Removed ${removedCount} items from virtual scroller data`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add keyboard navigation methods
|
||||
handlePageUpDown(direction) {
|
||||
// Prevent duplicate animations by checking last trigger time
|
||||
|
||||
@@ -95,21 +95,36 @@
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label>
|
||||
<label>{{ t('settings.civitaiApiKey') }}</label>
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<!-- Status display (shown when not editing) -->
|
||||
<div id="civitaiApiKeyStatus" class="api-key-status">
|
||||
<span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||
<i class="fas fa-times-circle text-error"></i>
|
||||
{{ t('settings.civitaiApiKeyNotConfigured') }}
|
||||
</span>
|
||||
<button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
|
||||
{{ t('settings.civitaiApiKeySet') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline edit view (shown when editing) -->
|
||||
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
|
||||
<div class="api-key-input">
|
||||
<input type="password"
|
||||
<input type="text"
|
||||
id="civitaiApiKey"
|
||||
class="api-key-masked"
|
||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||
autocomplete="new-password"
|
||||
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button class="toggle-visibility">
|
||||
autocomplete="off"
|
||||
data-mask="css" />
|
||||
<button type="button" class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
'messages': list([
|
||||
]),
|
||||
'settings': dict({
|
||||
'civitai_api_key': 'test-key',
|
||||
'civitai_api_key_set': True,
|
||||
'language': 'en',
|
||||
'theme': 'dark',
|
||||
}),
|
||||
|
||||
@@ -134,8 +134,10 @@ async def test_get_settings_excludes_no_sync_keys():
|
||||
|
||||
assert payload["success"] is True
|
||||
# Regular settings should be synced
|
||||
assert payload["settings"]["civitai_api_key"] == "abc"
|
||||
assert payload["settings"]["regular_setting"] == "value"
|
||||
# civitai_api_key is in _NO_SYNC_KEYS; only the boolean flag is returned
|
||||
assert payload["settings"].get("civitai_api_key") is None
|
||||
assert payload["settings"]["civitai_api_key_set"] is True
|
||||
# _NO_SYNC_KEYS should not be synced
|
||||
assert "hash_chunk_size_mb" not in payload["settings"]
|
||||
assert "folder_paths" not in payload["settings"]
|
||||
|
||||
Reference in New Issue
Block a user