fix(recipes): polish deleted-LoRA reconnect panel UI

- fix .reconnect-input overflow (calc(100% - 20px) -> border-box 100%)
- replace nested-card border/background with a dashed top separator
- route reconnect copy through translate(); add recipes.resources
  .reconnectInstructions/reconnectExample/reconnectPlaceholder keys
  and translate them in all 9 locales
- show reconnect failures inline in the panel (role=alert) instead of
  a transient toast; errors clear on input/show/hide
- drop dead .reconnect-instructions code CSS; add regression test
This commit is contained in:
Will Miao
2026-08-29 18:09:33 +08:00
parent fc9088bfd6
commit 6e31da7a70
13 changed files with 116 additions and 27 deletions
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "Download wird vorbereitet...", "preparingDownload": "Download wird vorbereitet...",
"reconnect": "Neu verknüpfen", "reconnect": "Neu verknüpfen",
"reconnectTooltip": "Mit einem lokalen LoRA neu verknüpfen", "reconnectTooltip": "Mit einem lokalen LoRA neu verknüpfen",
"reconnectInstructions": "Geben Sie die LoRA-Syntax oder den Namen zum Neuverknüpfen ein:",
"reconnectExample": "Beispiel: <lora:name:1> oder nur der Name",
"reconnectPlaceholder": "LoRA-Namen oder -Syntax eingeben",
"viewOnCivitai": "Auf CivitAI anzeigen", "viewOnCivitai": "Auf CivitAI anzeigen",
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen", "openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen" "openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "Preparing download...", "preparingDownload": "Preparing download...",
"reconnect": "Reconnect", "reconnect": "Reconnect",
"reconnectTooltip": "Reconnect with a local LoRA", "reconnectTooltip": "Reconnect with a local LoRA",
"reconnectInstructions": "Enter LoRA syntax or name to reconnect:",
"reconnectExample": "Example: <lora:name:1> or just the name",
"reconnectPlaceholder": "Enter LoRA name or syntax",
"viewOnCivitai": "View on CivitAI", "viewOnCivitai": "View on CivitAI",
"openLoraDetails": "View {name} in the LoRA library", "openLoraDetails": "View {name} in the LoRA library",
"openCheckpointDetails": "View {name} in the model library" "openCheckpointDetails": "View {name} in the model library"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "Preparando descarga...", "preparingDownload": "Preparando descarga...",
"reconnect": "Reconectar", "reconnect": "Reconectar",
"reconnectTooltip": "Reconectar con un LoRA local", "reconnectTooltip": "Reconectar con un LoRA local",
"reconnectInstructions": "Introduce la sintaxis o el nombre del LoRA para reconectar:",
"reconnectExample": "Ejemplo: <lora:name:1> o solo el nombre",
"reconnectPlaceholder": "Introduce el nombre o la sintaxis del LoRA",
"viewOnCivitai": "Ver en CivitAI", "viewOnCivitai": "Ver en CivitAI",
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs", "openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos" "openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "Préparation du téléchargement...", "preparingDownload": "Préparation du téléchargement...",
"reconnect": "Reconnecter", "reconnect": "Reconnecter",
"reconnectTooltip": "Reconnecter avec un LoRA local", "reconnectTooltip": "Reconnecter avec un LoRA local",
"reconnectInstructions": "Entrez la syntaxe ou le nom du LoRA à reconnecter:",
"reconnectExample": "Exemple: <lora:name:1> ou simplement le nom",
"reconnectPlaceholder": "Entrez le nom ou la syntaxe du LoRA",
"viewOnCivitai": "Voir sur CivitAI", "viewOnCivitai": "Voir sur CivitAI",
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA", "openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles" "openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "מכין את ההורדה...", "preparingDownload": "מכין את ההורדה...",
"reconnect": "חבר מחדש", "reconnect": "חבר מחדש",
"reconnectTooltip": "חבר מחדש עם LoRA מקומי", "reconnectTooltip": "חבר מחדש עם LoRA מקומי",
"reconnectInstructions": "הזן תחביר או שם של LoRA לחיבור מחדש:",
"reconnectExample": "דוגמה: <lora:name:1> או רק את השם",
"reconnectPlaceholder": "הזן שם או תחביר של LoRA",
"viewOnCivitai": "הצג ב-CivitAI", "viewOnCivitai": "הצג ב-CivitAI",
"openLoraDetails": "הצג את {name} בספריית ה-LoRA", "openLoraDetails": "הצג את {name} בספריית ה-LoRA",
"openCheckpointDetails": "הצג את {name} בספריית המודלים" "openCheckpointDetails": "הצג את {name} בספריית המודלים"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "ダウンロードを準備中...", "preparingDownload": "ダウンロードを準備中...",
"reconnect": "再接続", "reconnect": "再接続",
"reconnectTooltip": "ローカルの LoRA と再接続", "reconnectTooltip": "ローカルの LoRA と再接続",
"reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:",
"reconnectExample": "例:<lora:name:1> または名前のみ",
"reconnectPlaceholder": "LoRA 名または構文を入力",
"viewOnCivitai": "CivitAI で表示", "viewOnCivitai": "CivitAI で表示",
"openLoraDetails": "LoRA ライブラリで {name} を表示", "openLoraDetails": "LoRA ライブラリで {name} を表示",
"openCheckpointDetails": "モデルライブラリで {name} を表示" "openCheckpointDetails": "モデルライブラリで {name} を表示"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "다운로드 준비 중...", "preparingDownload": "다운로드 준비 중...",
"reconnect": "다시 연결", "reconnect": "다시 연결",
"reconnectTooltip": "로컬 LoRA와 다시 연결", "reconnectTooltip": "로컬 LoRA와 다시 연결",
"reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:",
"reconnectExample": "예:<lora:name:1> 또는 이름만 입력",
"reconnectPlaceholder": "LoRA 이름 또는 구문 입력",
"viewOnCivitai": "CivitAI에서 보기", "viewOnCivitai": "CivitAI에서 보기",
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기", "openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기" "openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "Подготовка к скачиванию...", "preparingDownload": "Подготовка к скачиванию...",
"reconnect": "Переподключить", "reconnect": "Переподключить",
"reconnectTooltip": "Переподключить к локальному LoRA", "reconnectTooltip": "Переподключить к локальному LoRA",
"reconnectInstructions": "Введите синтаксис или имя LoRA для переподключения:",
"reconnectExample": "Пример: <lora:name:1> или просто имя",
"reconnectPlaceholder": "Введите имя или синтаксис LoRA",
"viewOnCivitai": "Открыть на CivitAI", "viewOnCivitai": "Открыть на CivitAI",
"openLoraDetails": "Открыть {name} в библиотеке LoRA", "openLoraDetails": "Открыть {name} в библиотеке LoRA",
"openCheckpointDetails": "Открыть {name} в библиотеке моделей" "openCheckpointDetails": "Открыть {name} в библиотеке моделей"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "正在准备下载...", "preparingDownload": "正在准备下载...",
"reconnect": "重新关联", "reconnect": "重新关联",
"reconnectTooltip": "与本地 LoRA 重新关联", "reconnectTooltip": "与本地 LoRA 重新关联",
"reconnectInstructions": "输入 LoRA 语法或名称以重新关联:",
"reconnectExample": "示例:<lora:name:1> 或只填名称",
"reconnectPlaceholder": "输入 LoRA 名称或语法",
"viewOnCivitai": "在 CivitAI 上查看", "viewOnCivitai": "在 CivitAI 上查看",
"openLoraDetails": "在 LoRA 库中查看 {name}", "openLoraDetails": "在 LoRA 库中查看 {name}",
"openCheckpointDetails": "在模型库中查看 {name}" "openCheckpointDetails": "在模型库中查看 {name}"
+3
View File
@@ -903,6 +903,9 @@
"preparingDownload": "正在準備下載...", "preparingDownload": "正在準備下載...",
"reconnect": "重新關聯", "reconnect": "重新關聯",
"reconnectTooltip": "與本地 LoRA 重新關聯", "reconnectTooltip": "與本地 LoRA 重新關聯",
"reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:",
"reconnectExample": "範例:<lora:name:1> 或只填名稱",
"reconnectPlaceholder": "輸入 LoRA 名稱或語法",
"viewOnCivitai": "在 CivitAI 上檢視", "viewOnCivitai": "在 CivitAI 上檢視",
"openLoraDetails": "在 LoRA 庫中檢視 {name}", "openLoraDetails": "在 LoRA 庫中檢視 {name}",
"openCheckpointDetails": "在模型庫中檢視 {name}" "openCheckpointDetails": "在模型庫中檢視 {name}"
+18 -18
View File
@@ -966,14 +966,14 @@
/* Deleted badge is a pure status indicator; the reconnect action lives on /* Deleted badge is a pure status indicator; the reconnect action lives on
an explicit ghost button in the item's action row. */ an explicit ghost button in the item's action row. */
/* LoRA reconnect container */ /* LoRA reconnect container: an inline extension of the item, not a nested
card a dashed separator reads lighter than another bordered box inside
an already bordered item. */
.lora-reconnect-container { .lora-reconnect-container {
display: none; display: none;
flex-direction: column; flex-direction: column;
background: var(--lora-surface); border-top: 1px dashed var(--border-color);
border: 1px solid var(--border-color); padding-top: 10px;
border-radius: var(--border-radius-xs);
padding: 12px;
margin-top: 10px; margin-top: 10px;
gap: 10px; gap: 10px;
} }
@@ -1001,18 +1001,6 @@
font-size: 0.85em; font-size: 0.85em;
} }
.reconnect-instructions code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 0.9em;
}
[data-theme="dark"] .reconnect-instructions code {
background: rgba(255, 255, 255, 0.1);
}
.reconnect-form { .reconnect-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1020,7 +1008,8 @@
} }
.reconnect-input { .reconnect-input {
width: calc(100% - 20px); box-sizing: border-box;
width: 100%;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -1029,6 +1018,17 @@
font-size: 0.9em; font-size: 0.9em;
} }
.reconnect-error {
display: none;
margin: 0;
color: var(--lora-error);
font-size: 0.85em;
}
.reconnect-error.active {
display: block;
}
.reconnect-actions { .reconnect-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+32 -9
View File
@@ -963,16 +963,17 @@ class RecipeModal {
${isDeleted || lora.hashInvalid ? ` ${isDeleted || lora.hashInvalid ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}"> <div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p> <p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small> <small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
</div> </div>
<div class="reconnect-form"> <div class="reconnect-form">
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax"> <input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
<div class="reconnect-actions"> <div class="reconnect-actions">
<button class="reconnect-cancel-btn">Cancel</button> <button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
<button class="reconnect-confirm-btn">Reconnect</button> <button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
</div> </div>
</div> </div>
<p class="reconnect-error" role="alert"></p>
</div>` : ''} </div>` : ''}
</div> </div>
</div> </div>
@@ -1660,6 +1661,9 @@ class RecipeModal {
// Add keydown handlers to reconnect inputs // Add keydown handlers to reconnect inputs
const reconnectInputs = document.querySelectorAll('.reconnect-input'); const reconnectInputs = document.querySelectorAll('.reconnect-input');
reconnectInputs.forEach(input => { reconnectInputs.forEach(input => {
input.addEventListener('input', () => {
this.clearReconnectError(input.closest('.lora-reconnect-container'));
});
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
const container = input.closest('.lora-reconnect-container'); const container = input.closest('.lora-reconnect-container');
@@ -1683,6 +1687,7 @@ class RecipeModal {
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`); const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
if (container) { if (container) {
container.classList.add('active'); container.classList.add('active');
this.clearReconnectError(container);
const input = container.querySelector('.reconnect-input'); const input = container.querySelector('.reconnect-input');
input.focus(); input.focus();
} }
@@ -1691,14 +1696,33 @@ class RecipeModal {
hideReconnectInput(container) { hideReconnectInput(container) {
if (container && container.classList.contains('active')) { if (container && container.classList.contains('active')) {
container.classList.remove('active'); container.classList.remove('active');
this.clearReconnectError(container);
const input = container.querySelector('.reconnect-input'); const input = container.querySelector('.reconnect-input');
if (input) input.value = ''; if (input) input.value = '';
} }
} }
showReconnectError(container, message) {
const error = container && container.querySelector('.reconnect-error');
if (error) {
error.textContent = message;
error.classList.add('active');
}
}
clearReconnectError(container) {
const error = container && container.querySelector('.reconnect-error');
if (error) {
error.textContent = '';
error.classList.remove('active');
}
}
async reconnectLora(loraIndex, inputValue) { async reconnectLora(loraIndex, inputValue) {
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
if (!inputValue || !inputValue.trim()) { if (!inputValue || !inputValue.trim()) {
showToast('toast.recipes.enterLoraName', {}, 'error'); this.showReconnectError(container, translate('toast.recipes.enterLoraName', {}, 'Please enter a LoRA name or syntax'));
return; return;
} }
@@ -1729,7 +1753,6 @@ class RecipeModal {
if (result.success) { if (result.success) {
// Hide the reconnect input // Hide the reconnect input
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
this.hideReconnectInput(container); this.hideReconnectInput(container);
// Update the current recipe with the updated lora data // Update the current recipe with the updated lora data
@@ -1747,11 +1770,11 @@ class RecipeModal {
loras: this.currentRecipe.loras loras: this.currentRecipe.loras
}); });
} else { } else {
showToast('toast.recipes.reconnectFailed', { message: result.error }, 'error'); this.showReconnectError(container, translate('toast.recipes.reconnectFailed', { message: result.error }, `Error reconnecting LoRA: ${result.error}`));
} }
} catch (error) { } catch (error) {
console.error('Error reconnecting LoRA:', error); console.error('Error reconnecting LoRA:', error);
showToast('toast.recipes.reconnectFailed', { message: error.message }, 'error'); this.showReconnectError(container, translate('toast.recipes.reconnectFailed', { message: error.message }, `Error reconnecting LoRA: ${error.message}`));
} finally { } finally {
state.loadingManager.hide(); state.loadingManager.hide();
} }
@@ -330,6 +330,42 @@ describe('RecipeModal resource item interactions', () => {
expect(container.classList.contains('active')).toBe(true); expect(container.classList.contains('active')).toBe(true);
}); });
it('shows reconnect failures inline in the panel instead of a toast', async () => {
const recipeModal = await createRecipeModal();
global.fetch = vi.fn(async (url) => {
if (String(url).includes('/recipe/lora/reconnect')) {
return { ok: true, json: async () => ({ success: false, error: 'LoRA not found locally' }) };
}
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const deletedItem = document.querySelector('.recipe-lora-item.is-deleted');
deletedItem.querySelector('.lora-reconnect').click();
const container = deletedItem.querySelector('.lora-reconnect-container');
const input = container.querySelector('.reconnect-input');
const error = container.querySelector('.reconnect-error');
expect(error).not.toBeNull();
input.value = 'nonexistent-lora';
container.querySelector('.reconnect-confirm-btn').click();
await vi.waitFor(() => {
expect(error.classList.contains('active')).toBe(true);
});
expect(error.textContent).toContain('LoRA not found locally');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.reconnectFailed',
expect.anything(),
'error'
);
// Typing again clears the inline error
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(error.classList.contains('active')).toBe(false);
expect(error.textContent).toBe('');
});
it('renders hash-invalid LoRAs with a dedicated badge and reconnect instead of download', async () => { it('renders hash-invalid LoRAs with a dedicated badge and reconnect instead of download', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources); recipeModal.showRecipeDetails(recipeWithResources);