feat(recipes): improve recipe LoRA status indicators and missing-badge affordance (#1076)

- Recipe card: compact status pill with state icon + available/total
  fraction (e.g. "2/3"), pinned to the footer bottom-right like model
  card actions; status is encoded by icon + color, never color alone
- Recipe modal: "N missing" is now a real <button> with a persistent
  border, leading download icon, focus-visible ring and aria-label;
  clicking opens the download-missing flow
- Fix context menu missing-LoRA detection selector after badge refactor
- i18n: add recipes.status/loraStatus keys with translations for all
  10 locales, and fill pending rate-limit translations
This commit is contained in:
Will Miao
2026-08-27 22:37:10 +08:00
parent 17dcbd3d4f
commit 65ba750634
17 changed files with 564 additions and 102 deletions
@@ -37,8 +37,7 @@ export class RecipeContextMenu extends BaseContextMenu {
if (recipeId && missingLorasItem) {
// Check if this card has missing LoRAs
const loraCountElement = card.querySelector('.lora-count');
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
const hasMissingLoras = Boolean(card.querySelector('.lora-count.missing'));
// Show/hide the download missing LoRAs option based on missing status
if (hasMissingLoras) {
+20 -6
View File
@@ -45,6 +45,13 @@ class RecipeCard {
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Compact status pill: state icon + available/total fraction.
// Icon switches by state so status never relies on color alone.
const availableLorasCount = lorasCount - missingLorasCount;
const loraCountStateClass = missingLorasCount > 0 ? 'missing' : (allLorasAvailable ? 'ready' : '');
const loraCountIcon = missingLorasCount > 0 ? 'fa-exclamation-triangle' : (allLorasAvailable ? 'fa-check' : 'fa-layer-group');
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
// Ensure file_url exists, fallback to API URL if needed
let previewUrl = this.recipe.file_url;
if (!previewUrl) {
@@ -128,9 +135,8 @@ class RecipeCard {
<span class="model-name">${this.recipe.title}</span>
</div>
${!isDuplicatesMode ? `
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas fa-layer-group"></i> ${lorasCount}
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
</div>
` : ''}
</div>
@@ -149,9 +155,17 @@ class RecipeCard {
}
getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
if (totalCount === 0) {
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
}
if (missingCount === 0) {
return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use');
}
return translate(
'recipes.loraStatus.missing',
{ missing: missingCount, total: totalCount },
`${missingCount} of ${totalCount} LoRAs missing`
);
}
async toggleFavorite(card) {
+16 -27
View File
@@ -299,21 +299,6 @@ class RecipeModal {
tooltip.style.left = (badgeRect.right - tooltip.offsetWidth) + 'px';
}
}
// Add tooltip positioning for missing badge
if (event.target.closest('.recipe-status.missing')) {
const badge = event.target.closest('.recipe-status.missing');
const tooltip = badge.querySelector('.missing-tooltip');
if (tooltip) {
// Get badge position
const badgeRect = badge.getBoundingClientRect();
// Position the tooltip
tooltip.style.top = (badgeRect.bottom + 4) + 'px';
tooltip.style.left = (badgeRect.left) + 'px';
}
}
}, true);
}
@@ -872,30 +857,34 @@ class RecipeModal {
let statusHTML = '';
if (totalCount > 0) {
if (allLorasAvailable && deletedLorasCount === 0) {
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle" aria-hidden="true"></i> ${translate('recipes.status.ready', {}, 'Ready to use')}</div>`;
} else if (missingLorasCount > 0) {
statusHTML = `<div class="recipe-status missing">
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
<div class="missing-tooltip">Click to download missing LoRAs</div>
</div>`;
// Rendered as a real button so the affordance is visible without
// hover and the control is keyboard/screen-reader accessible.
// Leading download icon: the red tint + "missing" text already
// encode the state, so the icon's job is to hint the action.
statusHTML = `<button type="button" class="recipe-status missing clickable"
title="${translate('recipes.status.downloadMissingTooltip', {}, 'Click to download missing LoRAs')}"
aria-label="${translate('recipes.status.downloadMissing', { count: missingLorasCount }, `Download ${missingLorasCount} missing LoRAs`)}">
<i class="fas fa-download" aria-hidden="true"></i> ${translate('recipes.status.missingCount', { count: missingLorasCount }, `${missingLorasCount} missing`)}
</button>`;
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle" aria-hidden="true"></i> ${translate('recipes.status.deletedCount', { count: deletedLorasCount }, `${deletedLorasCount} deleted`)}</div>`;
}
}
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
const missingStatus = lorasCountElement.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) {
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
}
setTimeout(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
}
const missingStatus = document.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) {
missingStatus.classList.add('clickable');
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
}
}, 100);
}