feat: Introduce recipe favoriting with star icon toggle and filter options.

This commit is contained in:
Will Miao
2025-12-23 10:07:09 +08:00
parent 6e64f97e2b
commit db7f09797b
20 changed files with 486 additions and 307 deletions

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "Rezeptliste aktualisieren"
},
"filteredByLora": "Gefiltert nach LoRA"
"filteredByLora": "Gefiltert nach LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "{count} Duplikat-Gruppen gefunden",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "Refresh recipe list"
},
"filteredByLora": "Filtered by LoRA"
"filteredByLora": "Filtered by LoRA",
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
}
},
"duplicates": {
"found": "Found {count} duplicate groups",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "Actualizar lista de recetas"
},
"filteredByLora": "Filtrado por LoRA"
"filteredByLora": "Filtrado por LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "Se encontraron {count} grupos de duplicados",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "Actualiser la liste des recipes"
},
"filteredByLora": "Filtré par LoRA"
"filteredByLora": "Filtré par LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "Trouvé {count} groupes de doublons",

View File

@@ -228,6 +228,7 @@
"videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה",
"folderSettings": "הגדרות תיקייה",
"priorityTags": "תגיות עדיפות",
"downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון",
@@ -235,8 +236,7 @@
"misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי",
"priorityTags": "תגיות עדיפות"
"proxySettings": "הגדרות פרוקסי"
},
"storage": {
"locationLabel": "מצב נייד",
@@ -309,6 +309,26 @@
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
},
"downloadPathTemplates": {
"title": "תבניות נתיב הורדה",
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
@@ -320,8 +340,8 @@
"byFirstTag": "לפי תגית ראשונה",
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
"baseModelAuthor": "מודל בסיס + יוצר",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"authorFirstTag": "יוצר + תגית ראשונה",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"customTemplate": "תבנית מותאמת אישית"
},
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
@@ -409,26 +429,6 @@
"proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
}
},
"loras": {
@@ -591,7 +591,11 @@
"refresh": {
"title": "רענן רשימת מתכונים"
},
"filteredByLora": "מסונן לפי LoRA"
"filteredByLora": "מסונן לפי LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "נמצאו {count} קבוצות כפולות",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "レシピリストを更新"
},
"filteredByLora": "LoRAでフィルタ済み"
"filteredByLora": "LoRAでフィルタ済み",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "{count} 個の重複グループが見つかりました",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "레시피 목록 새로고침"
},
"filteredByLora": "LoRA로 필터링됨"
"filteredByLora": "LoRA로 필터링됨",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "{count}개의 중복 그룹 발견",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "Обновить список рецептов"
},
"filteredByLora": "Фильтр по LoRA"
"filteredByLora": "Фильтр по LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "Найдено {count} групп дубликатов",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "刷新配方列表"
},
"filteredByLora": "按 LoRA 筛选"
"filteredByLora": "按 LoRA 筛选",
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
}
},
"duplicates": {
"found": "发现 {count} 个重复组",

View File

@@ -591,7 +591,11 @@
"refresh": {
"title": "重新整理配方列表"
},
"filteredByLora": "已依 LoRA 篩選"
"filteredByLora": "已依 LoRA 篩選",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
},
"duplicates": {
"found": "發現 {count} 組重複項",

View File

@@ -170,6 +170,9 @@ class RecipeListingHandler:
if base_models:
filters["base_model"] = base_models.split(",")
if request.query.get("favorite", "false").lower() == "true":
filters["favorite"] = True
tag_filters: Dict[str, str] = {}
legacy_tags = request.query.get("tags")
if legacy_tags:

View File

@@ -1122,6 +1122,13 @@ class RecipeScanner:
if item.get('base_model', '') in filters['base_model']
]
# Filter by favorite
if 'favorite' in filters and filters['favorite']:
filtered_data = [
item for item in filtered_data
if item.get('favorite') is True
]
# Filter by tags
if 'tags' in filters and filters['tags']:
tag_spec = filters['tags']

View File

@@ -173,9 +173,9 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")):
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
)
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)

View File

@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
self.locales_dir = locales_dir
self.verbose = verbose
self.reference_locale = 'en'
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled."""

View File

@@ -47,6 +47,10 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
sort_by: pageState.sortBy
});
if (pageState.showFavoritesOnly) {
params.append('favorite', 'true');
}
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false);

View File

@@ -1,5 +1,6 @@
// Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
@@ -44,8 +45,11 @@ class RecipeCard {
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
const isDuplicatesMode = getCurrentPageState().duplicatesMode;
const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true;
const isFavorite = this.recipe.favorite === true;
// Video preview logic
const autoplayOnHover = state.settings.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
@@ -60,10 +64,6 @@ class RecipeCard {
videoAttrs.push('data-autoplay="true"');
}
// Check if in duplicates mode
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
@@ -96,6 +96,7 @@ class RecipeCard {
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i>
@@ -141,6 +142,67 @@ class RecipeCard {
return `${missingCount} of ${totalCount} LoRAs missing`;
}
async toggleFavorite(card) {
// Find the latest star icon in case the card was re-rendered
const getStarIcon = (c) => c.querySelector('.fa-star');
let starIcon = getStarIcon(card);
const isFavorite = this.recipe.favorite || false;
const newFavoriteState = !isFavorite;
// Update early to provide instant feedback and avoid race conditions with re-renders
this.recipe.favorite = newFavoriteState;
// Function to update icon state
const updateIconUI = (icon, state) => {
if (!icon) return;
if (state) {
icon.classList.remove('far');
icon.classList.add('fas', 'favorite-active');
icon.title = 'Remove from Favorites';
} else {
icon.classList.remove('fas', 'favorite-active');
icon.classList.add('far');
icon.title = 'Add to Favorites';
}
};
// Update current icon immediately
updateIconUI(starIcon, newFavoriteState);
try {
await updateRecipeMetadata(this.recipe.file_path, {
favorite: newFavoriteState
});
// Status already updated, just show toast
if (newFavoriteState) {
showToast('modelCard.favorites.added', {}, 'success');
} else {
showToast('modelCard.favorites.removed', {}, 'success');
}
// Re-find star icon after API call as VirtualScroller might have replaced the element
// During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card
// We need to find the NEW element in the DOM to ensure we don't have a stale reference
// Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite
// we will check the DOM just to be sure if this instance's internal card is still what's in DOM
} catch (error) {
console.error('Failed to update favorite status:', error);
// Revert local state on error
this.recipe.favorite = isFavorite;
// Re-find star icon in case of re-render during fault
const currentCard = card.ownerDocument.evaluate(
`.//*[@data-filepath="${this.recipe.file_path}"]`,
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue || card;
updateIconUI(getStarIcon(currentCard), isFavorite);
showToast('modelCard.favorites.updateFailed', {}, 'error');
}
}
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred
if (shouldBlur) {
@@ -172,6 +234,12 @@ class RecipeCard {
this.clickHandler(this.recipe);
});
// Favorite button click event - prevent propagation to card
card.querySelector('.fa-star')?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(card);
});
// Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation();

View File

@@ -225,6 +225,15 @@ class RecipeManager {
if (bulkButton) {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => {
this.pageState.showFavoritesOnly = !this.pageState.showFavoritesOnly;
favoriteFilterBtn.classList.toggle('active', this.pageState.showFavoritesOnly);
refreshVirtualScroll();
});
}
}
// This method is kept for compatibility but now uses virtual scrolling

View File

@@ -12,7 +12,7 @@ export class VirtualScroller {
this.scrollContainer = options.scrollContainer || this.containerElement;
this.batchSize = options.batchSize || 50;
this.pageSize = options.pageSize || 100;
this.itemAspectRatio = 896/1152; // Aspect ratio of cards
this.itemAspectRatio = 896 / 1152; // Aspect ratio of cards
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add container padding properties
@@ -716,7 +716,7 @@ export class VirtualScroller {
// Utility method for debouncing
debounce(func, wait) {
let timeout;
return function(...args) {
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
@@ -783,31 +783,30 @@ export class VirtualScroller {
deepMerge(target, source) {
if (!source || !target) return target;
// Initialize result with a copy of target
const result = { ...target };
// Only iterate over keys that exist in target
Object.keys(target).forEach(key => {
// Check if source has this key
if (source.hasOwnProperty(key)) {
const targetValue = target[key];
const sourceValue = source[key];
if (!source) return result;
// If both values are non-null objects and not arrays, merge recursively
if (
targetValue !== null &&
typeof targetValue === 'object' &&
!Array.isArray(targetValue) &&
sourceValue !== null &&
typeof sourceValue === 'object' &&
!Array.isArray(sourceValue)
) {
result[key] = this.deepMerge(targetValue, sourceValue);
} else {
// For primitive types, arrays, or null, use the value from source
result[key] = sourceValue;
}
// Iterate over all keys in the source object
Object.keys(source).forEach(key => {
const targetValue = target[key];
const sourceValue = source[key];
// If both values are non-null objects and not arrays, merge recursively
if (
targetValue !== null &&
typeof targetValue === 'object' &&
!Array.isArray(targetValue) &&
sourceValue !== null &&
typeof sourceValue === 'object' &&
!Array.isArray(sourceValue)
) {
result[key] = this.deepMerge(targetValue || {}, sourceValue);
} else {
// Otherwise update with source value (includes primitives, arrays, and new keys)
result[key] = sourceValue;
}
// If source does not have this key, keep the original value from target
});
return result;

View File

@@ -59,6 +59,12 @@
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
t('loras.controls.duplicates.action') }}</button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
title="{{ t('recipes.controls.favorites.title') }}">
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>

View File

@@ -502,7 +502,50 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
persisted2 = json.loads(recipe2_path.read_text())
assert persisted2["loras"][0]["file_name"] == "other_lora"
# Check cache
cache = await scanner.get_cached_data()
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
assert cached1["loras"][0]["file_name"] == new_name
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
scanner, _ = recipe_scanner
# Add a normal recipe
await scanner.add_recipe({
"id": "regular",
"file_path": "path/regular.png",
"title": "Regular Recipe",
"modified": 1.0,
"created_date": 1.0,
"loras": [],
})
# Add a favorite recipe
await scanner.add_recipe({
"id": "favorite",
"file_path": "path/favorite.png",
"title": "Favorite Recipe",
"modified": 2.0,
"created_date": 2.0,
"loras": [],
"favorite": True
})
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
await asyncio.sleep(0)
# Test without filter (should return both)
result_all = await scanner.get_paginated_data(page=1, page_size=10)
assert len(result_all["items"]) == 2
# Test with favorite filter
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
assert len(result_fav["items"]) == 1
assert result_fav["items"][0]["id"] == "favorite"
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
assert len(result_fav_false["items"]) == 2