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": { "refresh": {
"title": "Rezeptliste aktualisieren" "title": "Rezeptliste aktualisieren"
}, },
"filteredByLora": "Gefiltert nach LoRA" "filteredByLora": "Gefiltert nach LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
}, },
"duplicates": { "duplicates": {
"found": "{count} Duplikat-Gruppen gefunden", "found": "{count} Duplikat-Gruppen gefunden",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

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

View File

@@ -591,7 +591,11 @@
"refresh": { "refresh": {
"title": "Actualizar lista de recetas" "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": { "duplicates": {
"found": "Se encontraron {count} grupos de duplicados", "found": "Se encontraron {count} grupos de duplicados",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -591,7 +591,11 @@
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes" "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": { "duplicates": {
"found": "Trouvé {count} groupes de doublons", "found": "Trouvé {count} groupes de doublons",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -228,6 +228,7 @@
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"folderSettings": "הגדרות תיקייה", "folderSettings": "הגדרות תיקייה",
"priorityTags": "תגיות עדיפות",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון", "updateFlags": "תגי עדכון",
@@ -235,8 +236,7 @@
"misc": "שונות", "misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות", "storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי", "proxySettings": "הגדרות פרוקסי"
"priorityTags": "תגיות עדיפות"
}, },
"storage": { "storage": {
"locationLabel": "מצב נייד", "locationLabel": "מצב נייד",
@@ -309,6 +309,26 @@
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "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": { "downloadPathTemplates": {
"title": "תבניות נתיב הורדה", "title": "תבניות נתיב הורדה",
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.", "help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
@@ -320,8 +340,8 @@
"byFirstTag": "לפי תגית ראשונה", "byFirstTag": "לפי תגית ראשונה",
"baseModelFirstTag": "מודל בסיס + תגית ראשונה", "baseModelFirstTag": "מודל בסיס + תגית ראשונה",
"baseModelAuthor": "מודל בסיס + יוצר", "baseModelAuthor": "מודל בסיס + יוצר",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"authorFirstTag": "יוצר + תגית ראשונה", "authorFirstTag": "יוצר + תגית ראשונה",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"customTemplate": "תבנית מותאמת אישית" "customTemplate": "תבנית מותאמת אישית"
}, },
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})", "customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
@@ -409,26 +429,6 @@
"proxyPassword": "סיסמה (אופציונלי)", "proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" "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": { "loras": {
@@ -591,7 +591,11 @@
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים" "title": "רענן רשימת מתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA" "filteredByLora": "מסונן לפי LoRA",
"favorites": {
"title": "[TODO: Translate] Show Favorites Only",
"action": "[TODO: Translate] Favorites"
}
}, },
"duplicates": { "duplicates": {
"found": "נמצאו {count} קבוצות כפולות", "found": "נמצאו {count} קבוצות כפולות",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1122,6 +1122,13 @@ class RecipeScanner:
if item.get('base_model', '') in filters['base_model'] 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 # Filter by tags
if 'tags' in filters and filters['tags']: if 'tags' in filters and filters['tags']:
tag_spec = 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: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """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( 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) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)

View File

@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
self.locales_dir = locales_dir self.locales_dir = locales_dir
self.verbose = verbose self.verbose = verbose
self.reference_locale = 'en' 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'): def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled.""" """Log a message if verbose mode is enabled."""

View File

@@ -39,7 +39,7 @@ export function extractRecipeId(filePath) {
*/ */
export async function fetchRecipesPage(page = 1, pageSize = 100) { export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page, page: page,
@@ -47,24 +47,28 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
sort_by: pageState.sortBy sort_by: pageState.sortBy
}); });
if (pageState.showFavoritesOnly) {
params.append('favorite', 'true');
}
if (pageState.activeFolder) { if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder); params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false); params.append('recursive', pageState.searchOptions?.recursive !== false);
} else if (pageState.searchOptions?.recursive !== undefined) { } else if (pageState.searchOptions?.recursive !== undefined) {
params.append('recursive', pageState.searchOptions.recursive); params.append('recursive', pageState.searchOptions.recursive);
} }
// If we have a specific recipe ID to load // If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe // Special case: load specific recipe
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`); throw new Error(`Failed to load recipe: ${response.statusText}`);
} }
const recipe = await response.json(); const recipe = await response.json();
// Return in expected format // Return in expected format
return { return {
items: [recipe], items: [recipe],
@@ -74,18 +78,18 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
hasMore: false hasMore: false
}; };
} }
// Add custom filter for Lora if present // Add custom filter for Lora if present
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash); params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true'); params.append('bypass_filters', 'true');
} else { } else {
// Normal filtering logic // Normal filtering logic
// Add search filter if present // Add search filter if present
if (pageState.filters?.search) { if (pageState.filters?.search) {
params.append('search', pageState.filters.search); params.append('search', pageState.filters.search);
// Add search option parameters // Add search option parameters
if (pageState.searchOptions) { if (pageState.searchOptions) {
params.append('search_title', pageState.searchOptions.title.toString()); params.append('search_title', pageState.searchOptions.title.toString());
@@ -95,12 +99,12 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
params.append('fuzzy', 'true'); params.append('fuzzy', 'true');
} }
} }
// Add base model filters // Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) { if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
params.append('base_models', pageState.filters.baseModel.join(',')); params.append('base_models', pageState.filters.baseModel.join(','));
} }
// Add tag filters // Add tag filters
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) { if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -115,13 +119,13 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// Fetch recipes // Fetch recipes
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`); const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`); throw new Error(`Failed to load recipes: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
return { return {
items: data.items, items: data.items,
totalItems: data.total, totalItems: data.total,
@@ -147,29 +151,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
pageState.isLoading = true; pageState.isLoading = true;
// Reset page counter // Reset page counter
pageState.currentPage = 1; pageState.currentPage = 1;
// Fetch the first page // Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50); const result = await fetchPageFunction(1, pageState.pageSize || 50);
// Update the virtual scroller // Update the virtual scroller
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2 pageState.currentPage = 2; // Next page will be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error reloading ${modelType}s:`, error); console.error(`Error reloading ${modelType}s:`, error);
@@ -192,32 +196,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
// Start loading state // Start loading state
pageState.isLoading = true; pageState.isLoading = true;
// Reset to first page if requested // Reset to first page if requested
if (resetPage) { if (resetPage) {
pageState.currentPage = 1; pageState.currentPage = 1;
} }
// Fetch the first page of data // Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
// Update virtual scroller with the new data // Update virtual scroller with the new data
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2 pageState.currentPage = 2; // Next page to load would be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error loading ${modelType}s:`, error); console.error(`Error loading ${modelType}s:`, error);
@@ -247,18 +251,18 @@ export async function resetAndReload(updateFolders = false) {
export async function refreshRecipes() { export async function refreshRecipes() {
try { try {
state.loadingManager.showSimpleLoading('Refreshing recipes...'); state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache // Call the API endpoint to rebuild the recipe cache
const response = await fetch(RECIPE_ENDPOINTS.scan); const response = await fetch(RECIPE_ENDPOINTS.scan);
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache'); throw new Error(data.error || 'Failed to refresh recipe cache');
} }
// After successful cache rebuild, reload the recipes // After successful cache rebuild, reload the recipes
await resetAndReload(); await resetAndReload();
showToast('toast.recipes.refreshComplete', {}, 'success'); showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) { } catch (error) {
console.error('Error refreshing recipes:', error); console.error('Error refreshing recipes:', error);
@@ -276,7 +280,7 @@ export async function refreshRecipes() {
*/ */
export async function loadMoreRecipes(resetPage = false) { export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
// Use virtual scroller if available // Use virtual scroller if available
if (state.virtualScroller) { if (state.virtualScroller) {
return loadMoreWithVirtualScroll({ return loadMoreWithVirtualScroll({
@@ -317,7 +321,7 @@ export async function updateRecipeMetadata(filePath, updates) {
if (!recipeId) { if (!recipeId) {
throw new Error('Unable to determine recipe ID'); throw new Error('Unable to determine recipe ID');
} }
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -334,7 +338,7 @@ export async function updateRecipeMetadata(filePath, updates) {
} }
state.virtualScroller.updateSingleItem(filePath, updates); state.virtualScroller.updateSingleItem(filePath, updates);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error updating recipe:', error); console.error('Error updating recipe:', error);

View File

@@ -1,5 +1,6 @@
// Recipe Card Component // Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js'; import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.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()}` : (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png'); '/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 // Video preview logic
const autoplayOnHover = state.settings.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm'); const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [ const videoAttrs = [
'controls', 'controls',
@@ -60,10 +64,6 @@ class RecipeCard {
videoAttrs.push('data-autoplay="true"'); videoAttrs.push('data-autoplay="true"');
} }
// Check if in duplicates mode
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; 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; const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
@@ -96,6 +96,7 @@ class RecipeCard {
</button>` : ''} </button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span> <span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
<div class="card-actions"> <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-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-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i> <i class="fas fa-trash" title="Delete Recipe"></i>
@@ -141,6 +142,67 @@ class RecipeCard {
return `${missingCount} of ${totalCount} LoRAs missing`; 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) { attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred // Add blur toggle functionality if content should be blurred
if (shouldBlur) { if (shouldBlur) {
@@ -172,6 +234,12 @@ class RecipeCard {
this.clickHandler(this.recipe); 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 // Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -42,20 +42,20 @@ class RecipeManager {
// Page controls for shared sidebar behaviors // Page controls for shared sidebar behaviors
this.pageControls = new RecipePageControls(); this.pageControls = new RecipePageControls();
// Initialize ImportManager // Initialize ImportManager
this.importManager = new ImportManager(); this.importManager = new ImportManager();
// Initialize RecipeModal // Initialize RecipeModal
this.recipeModal = new RecipeModal(); this.recipeModal = new RecipeModal();
// Initialize DuplicatesManager // Initialize DuplicatesManager
this.duplicatesManager = new DuplicatesManager(this); this.duplicatesManager = new DuplicatesManager(this);
// Add state tracking for infinite scroll // Add state tracking for infinite scroll
this.pageState.isLoading = false; this.pageState.isLoading = false;
this.pageState.hasMore = true; this.pageState.hasMore = true;
// Custom filter state - move to pageState for compatibility with virtual scrolling // Custom filter state - move to pageState for compatibility with virtual scrolling
this.pageState.customFilter = { this.pageState.customFilter = {
active: false, active: false,
@@ -64,26 +64,26 @@ class RecipeManager {
recipeId: null recipeId: null
}; };
} }
async initialize() { async initialize() {
// Initialize event listeners // Initialize event listeners
this.initEventListeners(); this.initEventListeners();
// Set default search options if not already defined // Set default search options if not already defined
this._initSearchOptions(); this._initSearchOptions();
// Initialize context menu // Initialize context menu
new RecipeContextMenu(); new RecipeContextMenu();
// Check for custom filter parameters in session storage // Check for custom filter parameters in session storage
this._checkCustomFilter(); this._checkCustomFilter();
// Expose necessary functions to the page // Expose necessary functions to the page
this._exposeGlobalFunctions(); this._exposeGlobalFunctions();
// Initialize sidebar navigation // Initialize sidebar navigation
await this._initSidebar(); await this._initSidebar();
// Initialize common page features // Initialize common page features
appCore.initializePageFeatures(); appCore.initializePageFeatures();
} }
@@ -97,7 +97,7 @@ class RecipeManager {
console.error('Failed to initialize recipe sidebar:', error); console.error('Failed to initialize recipe sidebar:', error);
} }
} }
_initSearchOptions() { _initSearchOptions() {
// Ensure recipes search options are properly initialized // Ensure recipes search options are properly initialized
if (!this.pageState.searchOptions) { if (!this.pageState.searchOptions) {
@@ -110,21 +110,21 @@ class RecipeManager {
}; };
} }
} }
_exposeGlobalFunctions() { _exposeGlobalFunctions() {
// Only expose what's needed for the page // Only expose what's needed for the page
window.recipeManager = this; window.recipeManager = this;
window.importManager = this.importManager; window.importManager = this.importManager;
} }
_checkCustomFilter() { _checkCustomFilter() {
// Check for Lora filter // Check for Lora filter
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName'); const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash'); const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
// Check for specific recipe ID // Check for specific recipe ID
const viewRecipeId = getSessionItem('viewRecipeId'); const viewRecipeId = getSessionItem('viewRecipeId');
// Set custom filter if any parameter is present // Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) { if (filterLoraName || filterLoraHash || viewRecipeId) {
this.pageState.customFilter = { this.pageState.customFilter = {
@@ -133,35 +133,35 @@ class RecipeManager {
loraHash: filterLoraHash, loraHash: filterLoraHash,
recipeId: viewRecipeId recipeId: viewRecipeId
}; };
// Show custom filter indicator // Show custom filter indicator
this._showCustomFilterIndicator(); this._showCustomFilterIndicator();
} }
} }
_showCustomFilterIndicator() { _showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText'); const textElement = document.getElementById('customFilterText');
if (!indicator || !textElement) return; if (!indicator || !textElement) return;
// Update text based on filter type // Update text based on filter type
let filterText = ''; let filterText = '';
if (this.pageState.customFilter.recipeId) { if (this.pageState.customFilter.recipeId) {
filterText = 'Viewing specific recipe'; filterText = 'Viewing specific recipe';
} else if (this.pageState.customFilter.loraName) { } else if (this.pageState.customFilter.loraName) {
// Format with Lora name // Format with Lora name
const loraName = this.pageState.customFilter.loraName; const loraName = this.pageState.customFilter.loraName;
const displayName = loraName.length > 25 ? const displayName = loraName.length > 25 ?
loraName.substring(0, 22) + '...' : loraName.substring(0, 22) + '...' :
loraName; loraName;
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`; filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
} else { } else {
filterText = 'Filtered recipes'; filterText = 'Filtered recipes';
} }
// Update indicator text and show it // Update indicator text and show it
textElement.innerHTML = filterText; textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip // Add title attribute to show the lora name as a tooltip
@@ -169,14 +169,14 @@ class RecipeManager {
textElement.setAttribute('title', this.pageState.customFilter.loraName); textElement.setAttribute('title', this.pageState.customFilter.loraName);
} }
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
// Add pulse animation // Add pulse animation
const filterElement = indicator.querySelector('.filter-active'); const filterElement = indicator.querySelector('.filter-active');
if (filterElement) { if (filterElement) {
filterElement.classList.add('animate'); filterElement.classList.add('animate');
setTimeout(() => filterElement.classList.remove('animate'), 600); setTimeout(() => filterElement.classList.remove('animate'), 600);
} }
// Add click handler for clear filter button // Add click handler for clear filter button
const clearFilterBtn = indicator.querySelector('.clear-filter'); const clearFilterBtn = indicator.querySelector('.clear-filter');
if (clearFilterBtn) { if (clearFilterBtn) {
@@ -186,7 +186,7 @@ class RecipeManager {
}); });
} }
} }
_clearCustomFilter() { _clearCustomFilter() {
// Reset custom filter // Reset custom filter
this.pageState.customFilter = { this.pageState.customFilter = {
@@ -195,22 +195,22 @@ class RecipeManager {
loraHash: null, loraHash: null,
recipeId: null recipeId: null
}; };
// Hide indicator // Hide indicator
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
if (indicator) { if (indicator) {
indicator.classList.add('hidden'); indicator.classList.add('hidden');
} }
// Clear any session storage items // Clear any session storage items
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Reset and refresh the virtual scroller // Reset and refresh the virtual scroller
refreshVirtualScroll(); refreshVirtualScroll();
} }
initEventListeners() { initEventListeners() {
// Sort select // Sort select
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
@@ -225,8 +225,17 @@ class RecipeManager {
if (bulkButton) { if (bulkButton) {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode()); 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 // This method is kept for compatibility but now uses virtual scrolling
async loadRecipes(resetPage = true) { async loadRecipes(resetPage = true) {
// Skip loading if in duplicates mode // Skip loading if in duplicates mode
@@ -234,32 +243,32 @@ class RecipeManager {
if (pageState.duplicatesMode) { if (pageState.duplicatesMode) {
return; return;
} }
if (resetPage) { if (resetPage) {
refreshVirtualScroll(); refreshVirtualScroll();
} }
} }
/** /**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes * Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/ */
async refreshRecipes() { async refreshRecipes() {
return refreshRecipes(); return refreshRecipes();
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
this.recipeModal.showRecipeDetails(recipe); this.recipeModal.showRecipeDetails(recipe);
} }
// Duplicate detection and management methods // Duplicate detection and management methods
async findDuplicateRecipes() { async findDuplicateRecipes() {
return await this.duplicatesManager.findDuplicates(); return await this.duplicatesManager.findDuplicates();
} }
selectLatestDuplicates() { selectLatestDuplicates() {
this.duplicatesManager.selectLatestDuplicates(); this.duplicatesManager.selectLatestDuplicates();
} }
deleteSelectedDuplicates() { deleteSelectedDuplicates() {
this.duplicatesManager.deleteSelectedDuplicates(); this.duplicatesManager.deleteSelectedDuplicates();
} }
@@ -267,14 +276,14 @@ class RecipeManager {
confirmDeleteDuplicates() { confirmDeleteDuplicates() {
this.duplicatesManager.confirmDeleteDuplicates(); this.duplicatesManager.confirmDeleteDuplicates();
} }
exitDuplicateMode() { exitDuplicateMode() {
// Clear the grid first to prevent showing old content temporarily // Clear the grid first to prevent showing old content temporarily
const recipeGrid = document.getElementById('recipeGrid'); const recipeGrid = document.getElementById('recipeGrid');
if (recipeGrid) { if (recipeGrid) {
recipeGrid.innerHTML = ''; recipeGrid.innerHTML = '';
} }
this.duplicatesManager.exitDuplicateMode(); this.duplicatesManager.exitDuplicateMode();
} }
} }
@@ -283,7 +292,7 @@ class RecipeManager {
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application // Initialize core application
await appCore.initialize(); await appCore.initialize();
// Initialize recipe manager // Initialize recipe manager
const recipeManager = new RecipeManager(); const recipeManager = new RecipeManager();
await recipeManager.initialize(); await recipeManager.initialize();

View File

@@ -12,13 +12,13 @@ export class VirtualScroller {
this.scrollContainer = options.scrollContainer || this.containerElement; this.scrollContainer = options.scrollContainer || this.containerElement;
this.batchSize = options.batchSize || 50; this.batchSize = options.batchSize || 50;
this.pageSize = options.pageSize || 100; 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) this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add container padding properties // Add container padding properties
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
// Add data windowing enable/disable flag // Add data windowing enable/disable flag
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false; this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
@@ -73,15 +73,15 @@ export class VirtualScroller {
this.spacerElement.style.width = '100%'; this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
this.spacerElement.style.pointerEvents = 'none'; this.spacerElement.style.pointerEvents = 'none';
// The grid will be used for the actual visible items // The grid will be used for the actual visible items
this.gridElement.style.position = 'relative'; this.gridElement.style.position = 'relative';
this.gridElement.style.minHeight = '0'; this.gridElement.style.minHeight = '0';
// Apply padding directly to ensure consistency // Apply padding directly to ensure consistency
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`; this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`; this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
// Place the spacer inside the grid container // Place the spacer inside the grid container
this.gridElement.appendChild(this.spacerElement); this.gridElement.appendChild(this.spacerElement);
} }
@@ -97,16 +97,16 @@ export class VirtualScroller {
const containerStyle = getComputedStyle(this.containerElement); const containerStyle = getComputedStyle(this.containerElement);
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
// Calculate available content width (excluding padding) // Calculate available content width (excluding padding)
const availableContentWidth = containerWidth - paddingLeft - paddingRight; const availableContentWidth = containerWidth - paddingLeft - paddingRight;
// Get display density setting // Get display density setting
const displayDensity = state.global.settings?.display_density || 'default'; const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths // Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth; let maxColumns, maxGridWidth;
// Match exact column counts and CSS container width values based on density // Match exact column counts and CSS container width values based on density
if (window.innerWidth >= 3000) { // 4K if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') { if (displayDensity === 'default') {
@@ -137,17 +137,17 @@ export class VirtualScroller {
} }
maxGridWidth = 1400; // Match exact CSS container width for 1080p maxGridWidth = 1400; // Match exact CSS container width for 1080p
} }
// Calculate baseCardWidth based on desired column count and available space // Calculate baseCardWidth based on desired column count and available space
// Formula: (maxGridWidth - (columns-1)*gap) / columns // Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns; const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
// Use the smaller of available content width or max grid width // Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
// Set exact column count based on screen size and mode // Set exact column count based on screen size and mode
this.columnsCount = maxColumns; this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns // When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) { if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space // Calculate how many columns can fit in the available space
@@ -155,30 +155,30 @@ export class VirtualScroller {
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap) (availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
)); ));
} }
// Calculate actual item width // Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio // Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio; this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area // Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Update grid element max-width to match available width // Update grid element max-width to match available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.maxWidth = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments // Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
this.gridElement.classList.add(`${displayDensity}-density`); this.gridElement.classList.add(`${displayDensity}-density`);
// Update spacer height // Update spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
// Re-render with new layout // Re-render with new layout
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
return true; return true;
} }
@@ -186,20 +186,20 @@ export class VirtualScroller {
// Debounced scroll handler // Debounced scroll handler
this.scrollHandler = this.debounce(() => this.handleScroll(), 10); this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
this.scrollContainer.addEventListener('scroll', this.scrollHandler); this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Window resize handler for layout recalculation // Window resize handler for layout recalculation
this.resizeHandler = this.debounce(() => { this.resizeHandler = this.debounce(() => {
this.calculateLayout(); this.calculateLayout();
}, 150); }, 150);
window.addEventListener('resize', this.resizeHandler); window.addEventListener('resize', this.resizeHandler);
// Use ResizeObserver for more accurate container size detection // Use ResizeObserver for more accurate container size detection
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(this.debounce(() => { this.resizeObserver = new ResizeObserver(this.debounce(() => {
this.calculateLayout(); this.calculateLayout();
}, 150)); }, 150));
this.resizeObserver.observe(this.containerElement); this.resizeObserver.observe(this.containerElement);
} }
} }
@@ -217,35 +217,35 @@ export class VirtualScroller {
async loadInitialBatch() { async loadInitialBatch() {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (this.isLoading) return; if (this.isLoading) return;
this.isLoading = true; this.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety this.setLoadingTimeout(); // Add loading timeout safety
try { try {
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
// Initialize the data window with the first batch of items // Initialize the data window with the first batch of items
this.items = items || []; this.items = items || [];
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
this.dataWindow = { start: 0, end: this.items.length }; this.dataWindow = { start: 0, end: this.items.length };
this.absoluteWindowStart = 0; this.absoluteWindowStart = 0;
// Update the spacer height based on the total number of items // Update the spacer height based on the total number of items
this.updateSpacerHeight(); this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed // Check if there are no items and show placeholder if needed
if (this.items.length === 0) { if (this.items.length === 0) {
this.showNoItemsPlaceholder(); this.showNoItemsPlaceholder();
} else { } else {
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
} }
// Reset page state to sync with our virtual scroller // Reset page state to sync with our virtual scroller
pageState.currentPage = 2; // Next page to load would be 2 pageState.currentPage = 2; // Next page to load would be 2
pageState.hasMore = this.hasMore; pageState.hasMore = this.hasMore;
pageState.isLoading = false; pageState.isLoading = false;
return { items, totalItems, hasMore }; return { items, totalItems, hasMore };
} catch (err) { } catch (err) {
console.error('Failed to load initial batch:', err); console.error('Failed to load initial batch:', err);
@@ -260,36 +260,36 @@ export class VirtualScroller {
async loadMoreItems() { async loadMoreItems() {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (this.isLoading || !this.hasMore) return; if (this.isLoading || !this.hasMore) return;
this.isLoading = true; this.isLoading = true;
pageState.isLoading = true; pageState.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety this.setLoadingTimeout(); // Add loading timeout safety
try { try {
console.log('Loading more items, page:', pageState.currentPage); console.log('Loading more items, page:', pageState.currentPage);
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
if (items && items.length > 0) { if (items && items.length > 0) {
this.items = [...this.items, ...items]; this.items = [...this.items, ...items];
this.hasMore = hasMore; this.hasMore = hasMore;
pageState.hasMore = hasMore; pageState.hasMore = hasMore;
// Update page for next request // Update page for next request
pageState.currentPage++; pageState.currentPage++;
// Update the spacer height // Update the spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
// Render the newly loaded items if they're in view // Render the newly loaded items if they're in view
this.scheduleRender(); this.scheduleRender();
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`); console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
} else { } else {
this.hasMore = false; this.hasMore = false;
pageState.hasMore = false; pageState.hasMore = false;
console.log('No more items to load'); console.log('No more items to load');
} }
return items; return items;
} catch (err) { } catch (err) {
console.error('Failed to load more items:', err); console.error('Failed to load more items:', err);
@@ -305,7 +305,7 @@ export class VirtualScroller {
setLoadingTimeout() { setLoadingTimeout() {
// Clear any existing timeout first // Clear any existing timeout first
this.clearLoadingTimeout(); this.clearLoadingTimeout();
// Set a new timeout to prevent loading state from getting stuck // Set a new timeout to prevent loading state from getting stuck
this.loadingTimeout = setTimeout(() => { this.loadingTimeout = setTimeout(() => {
if (this.isLoading) { if (this.isLoading) {
@@ -326,15 +326,15 @@ export class VirtualScroller {
updateSpacerHeight() { updateSpacerHeight() {
if (this.columnsCount === 0) return; if (this.columnsCount === 0) return;
// Calculate total rows needed based on total items and columns // Calculate total rows needed based on total items and columns
const totalRows = Math.ceil(this.totalItems / this.columnsCount); const totalRows = Math.ceil(this.totalItems / this.columnsCount);
// Add row gaps to the total height calculation // Add row gaps to the total height calculation
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
// Include container padding in the total height // Include container padding in the total height
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom; const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
// Update spacer height to represent all items // Update spacer height to represent all items
this.spacerElement.style.height = `${spacerHeight}px`; this.spacerElement.style.height = `${spacerHeight}px`;
} }
@@ -342,28 +342,28 @@ export class VirtualScroller {
getVisibleRange() { getVisibleRange() {
const scrollTop = this.scrollContainer.scrollTop; const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight; const viewportHeight = this.scrollContainer.clientHeight;
// Calculate the visible row range, accounting for row gaps // Calculate the visible row range, accounting for row gaps
const rowHeight = this.itemHeight + this.rowGap; const rowHeight = this.itemHeight + this.rowGap;
const startRow = Math.floor(scrollTop / rowHeight); const startRow = Math.floor(scrollTop / rowHeight);
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight); const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
// Add overscan for smoother scrolling // Add overscan for smoother scrolling
const overscanRows = this.overscan; const overscanRows = this.overscan;
const firstRow = Math.max(0, startRow - overscanRows); const firstRow = Math.max(0, startRow - overscanRows);
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows); const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
// Calculate item indices // Calculate item indices
const firstIndex = firstRow * this.columnsCount; const firstIndex = firstRow * this.columnsCount;
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount); const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
return { start: firstIndex, end: lastIndex }; return { start: firstIndex, end: lastIndex };
} }
// Update the scheduleRender method to check for disabled state // Update the scheduleRender method to check for disabled state
scheduleRender() { scheduleRender() {
if (this.disabled || this.renderScheduled) return; if (this.disabled || this.renderScheduled) return;
this.renderScheduled = true; this.renderScheduled = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.renderItems(); this.renderItems();
@@ -374,25 +374,25 @@ export class VirtualScroller {
// Update the renderItems method to check for disabled state // Update the renderItems method to check for disabled state
renderItems() { renderItems() {
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
const { start, end } = this.getVisibleRange(); const { start, end } = this.getVisibleRange();
// Check if render range has significantly changed // Check if render range has significantly changed
const isSameRange = const isSameRange =
start >= this.lastRenderRange.start && start >= this.lastRenderRange.start &&
end <= this.lastRenderRange.end && end <= this.lastRenderRange.end &&
Math.abs(start - this.lastRenderRange.start) < 10; Math.abs(start - this.lastRenderRange.start) < 10;
if (isSameRange) return; if (isSameRange) return;
this.lastRenderRange = { start, end }; this.lastRenderRange = { start, end };
// Determine which items need to be added and removed // Determine which items need to be added and removed
const currentIndices = new Set(); const currentIndices = new Set();
for (let i = start; i < end && i < this.items.length; i++) { for (let i = start; i < end && i < this.items.length; i++) {
currentIndices.add(i); currentIndices.add(i);
} }
// Remove items that are no longer visible // Remove items that are no longer visible
for (const [index, element] of this.renderedItems.entries()) { for (const [index, element] of this.renderedItems.entries()) {
if (!currentIndices.has(index)) { if (!currentIndices.has(index)) {
@@ -400,10 +400,10 @@ export class VirtualScroller {
this.renderedItems.delete(index); this.renderedItems.delete(index);
} }
} }
// Use DocumentFragment for batch DOM operations // Use DocumentFragment for batch DOM operations
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Add new visible items to the fragment // Add new visible items to the fragment
for (let i = start; i < end && i < this.items.length; i++) { for (let i = start; i < end && i < this.items.length; i++) {
if (!this.renderedItems.has(i)) { if (!this.renderedItems.has(i)) {
@@ -413,17 +413,17 @@ export class VirtualScroller {
this.renderedItems.set(i, element); this.renderedItems.set(i, element);
} }
} }
// Add the fragment to the grid (single DOM operation) // Add the fragment to the grid (single DOM operation)
if (fragment.childNodes.length > 0) { if (fragment.childNodes.length > 0) {
this.gridElement.appendChild(fragment); this.gridElement.appendChild(fragment);
} }
// If we're close to the end and have more items to load, fetch them // If we're close to the end and have more items to load, fetch them
if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
this.loadMoreItems(); this.loadMoreItems();
} }
// Check if we need to slide the data window // Check if we need to slide the data window
this.slideDataWindow(); this.slideDataWindow();
} }
@@ -439,14 +439,14 @@ export class VirtualScroller {
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
this.updateSpacerHeight(); this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed // Check if there are no items and show placeholder if needed
if (this.items.length === 0) { if (this.items.length === 0) {
this.showNoItemsPlaceholder(); this.showNoItemsPlaceholder();
} else { } else {
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
} }
// Clear all rendered items and redraw // Clear all rendered items and redraw
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
@@ -455,29 +455,29 @@ export class VirtualScroller {
createItemElement(item, index) { createItemElement(item, index) {
// Create the DOM element // Create the DOM element
const element = this.createItemFn(item); const element = this.createItemFn(item);
// Add virtual scroll item class // Add virtual scroll item class
element.classList.add('virtual-scroll-item'); element.classList.add('virtual-scroll-item');
// Calculate the position // Calculate the position
const row = Math.floor(index / this.columnsCount); const row = Math.floor(index / this.columnsCount);
const col = index % this.columnsCount; const col = index % this.columnsCount;
// Calculate precise positions with row gap included // Calculate precise positions with row gap included
// Add the top padding to account for container padding // Add the top padding to account for container padding
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap)); const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
// Position correctly with leftOffset (no need to add padding as absolute // Position correctly with leftOffset (no need to add padding as absolute
// positioning is already relative to the padding edge of the container) // positioning is already relative to the padding edge of the container)
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
// Position the element with absolute positioning // Position the element with absolute positioning
element.style.position = 'absolute'; element.style.position = 'absolute';
element.style.left = `${leftPos}px`; element.style.left = `${leftPos}px`;
element.style.top = `${topPos}px`; element.style.top = `${topPos}px`;
element.style.width = `${this.itemWidth}px`; element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`; element.style.height = `${this.itemHeight}px`;
return element; return element;
} }
@@ -486,17 +486,17 @@ export class VirtualScroller {
const scrollTop = this.scrollContainer.scrollTop; const scrollTop = this.scrollContainer.scrollTop;
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
this.lastScrollTop = scrollTop; this.lastScrollTop = scrollTop;
// Handle large jumps in scroll position - check if we need to fetch a new window // Handle large jumps in scroll position - check if we need to fetch a new window
const { scrollHeight } = this.scrollContainer; const { scrollHeight } = this.scrollContainer;
const scrollRatio = scrollTop / scrollHeight; const scrollRatio = scrollTop / scrollHeight;
// Only perform data windowing if the feature is enabled // Only perform data windowing if the feature is enabled
if (this.enableDataWindowing && this.totalItems > this.windowSize) { if (this.enableDataWindowing && this.totalItems > this.windowSize) {
const estimatedIndex = Math.floor(scrollRatio * this.totalItems); const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
const currentWindowStart = this.absoluteWindowStart; const currentWindowStart = this.absoluteWindowStart;
const currentWindowEnd = currentWindowStart + this.items.length; const currentWindowEnd = currentWindowStart + this.items.length;
// If the estimated position is outside our current window by a significant amount // If the estimated position is outside our current window by a significant amount
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
// Fetch a new data window centered on the estimated position // Fetch a new data window centered on the estimated position
@@ -504,14 +504,14 @@ export class VirtualScroller {
return; // Skip normal rendering until new data is loaded return; // Skip normal rendering until new data is loaded
} }
} }
// Render visible items // Render visible items
this.scheduleRender(); this.scheduleRender();
// If we're near the bottom and have more items, load them // If we're near the bottom and have more items, load them
const { clientHeight } = this.scrollContainer; const { clientHeight } = this.scrollContainer;
const scrollBottom = scrollTop + clientHeight; const scrollBottom = scrollTop + clientHeight;
// Fix the threshold calculation - use percentage of remaining height instead // Fix the threshold calculation - use percentage of remaining height instead
// We'll trigger loading when within 20% of the bottom of rendered content // We'll trigger loading when within 20% of the bottom of rendered content
const remainingScroll = scrollHeight - scrollBottom; const remainingScroll = scrollHeight - scrollBottom;
@@ -521,9 +521,9 @@ export class VirtualScroller {
// Or when within 2 rows of content from the bottom, whichever is larger // Or when within 2 rows of content from the bottom, whichever is larger
(this.itemHeight + this.rowGap) * 2 (this.itemHeight + this.rowGap) * 2
); );
const shouldLoadMore = remainingScroll <= scrollThreshold; const shouldLoadMore = remainingScroll <= scrollThreshold;
if (shouldLoadMore && this.hasMore && !this.isLoading) { if (shouldLoadMore && this.hasMore && !this.isLoading) {
this.loadMoreItems(); this.loadMoreItems();
} }
@@ -533,40 +533,40 @@ export class VirtualScroller {
async fetchDataWindow(targetIndex) { async fetchDataWindow(targetIndex) {
// Skip if data windowing is disabled or already fetching // Skip if data windowing is disabled or already fetching
if (!this.enableDataWindowing || this.fetchingWindow) return; if (!this.enableDataWindowing || this.fetchingWindow) return;
this.fetchingWindow = true; this.fetchingWindow = true;
try { try {
// Calculate which page we need to fetch based on target index // Calculate which page we need to fetch based on target index
const targetPage = Math.floor(targetIndex / this.pageSize) + 1; const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
if (items && items.length > 0) { if (items && items.length > 0) {
// Calculate new absolute window start // Calculate new absolute window start
this.absoluteWindowStart = (targetPage - 1) * this.pageSize; this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
// Replace the entire data window with new items // Replace the entire data window with new items
this.items = items; this.items = items;
this.dataWindow = { this.dataWindow = {
start: 0, start: 0,
end: items.length end: items.length
}; };
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
// Update the current page for future fetches // Update the current page for future fetches
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
pageState.currentPage = targetPage + 1; pageState.currentPage = targetPage + 1;
pageState.hasMore = hasMore; pageState.hasMore = hasMore;
// Update the spacer height and clear current rendered items // Update the spacer height and clear current rendered items
this.updateSpacerHeight(); this.updateSpacerHeight();
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
} }
} catch (err) { } catch (err) {
@@ -581,37 +581,37 @@ export class VirtualScroller {
async slideDataWindow() { async slideDataWindow() {
// Skip if data windowing is disabled // Skip if data windowing is disabled
if (!this.enableDataWindowing) return; if (!this.enableDataWindowing) return;
const { start, end } = this.getVisibleRange(); const { start, end } = this.getVisibleRange();
const windowStart = this.dataWindow.start; const windowStart = this.dataWindow.start;
const windowEnd = this.dataWindow.end; const windowEnd = this.dataWindow.end;
const absoluteIndex = this.absoluteWindowStart + windowStart; const absoluteIndex = this.absoluteWindowStart + windowStart;
// Calculate the midpoint of the visible range // Calculate the midpoint of the visible range
const visibleMidpoint = Math.floor((start + end) / 2); const visibleMidpoint = Math.floor((start + end) / 2);
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
// Check if we're too close to the window edges // Check if we're too close to the window edges
const closeToStart = start - windowStart < this.windowPadding; const closeToStart = start - windowStart < this.windowPadding;
const closeToEnd = windowEnd - end < this.windowPadding; const closeToEnd = windowEnd - end < this.windowPadding;
// If we're close to either edge and have total items > window size // If we're close to either edge and have total items > window size
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
// Calculate a new target index centered around the current viewport // Calculate a new target index centered around the current viewport
const halfWindow = Math.floor(this.windowSize / 2); const halfWindow = Math.floor(this.windowSize / 2);
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
// Don't fetch a new window if we're already showing items near the beginning // Don't fetch a new window if we're already showing items near the beginning
if (targetIndex === 0 && this.absoluteWindowStart === 0) { if (targetIndex === 0 && this.absoluteWindowStart === 0) {
return; return;
} }
// Don't fetch if we're showing the end of the list and are near the end // Don't fetch if we're showing the end of the list and are near the end
if (this.absoluteWindowStart + this.items.length >= this.totalItems && if (this.absoluteWindowStart + this.items.length >= this.totalItems &&
this.totalItems - end < halfWindow) { this.totalItems - end < halfWindow) {
return; return;
} }
// Fetch the new data window // Fetch the new data window
await this.fetchDataWindow(targetIndex); await this.fetchDataWindow(targetIndex);
} }
@@ -620,18 +620,18 @@ export class VirtualScroller {
reset() { reset() {
// Remove all rendered items // Remove all rendered items
this.clearRenderedItems(); this.clearRenderedItems();
// Reset state // Reset state
this.items = []; this.items = [];
this.totalItems = 0; this.totalItems = 0;
this.hasMore = true; this.hasMore = true;
// Reset spacer height // Reset spacer height
this.spacerElement.style.height = '0px'; this.spacerElement.style.height = '0px';
// Remove any placeholder // Remove any placeholder
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
// Schedule a re-render // Schedule a re-render
this.scheduleRender(); this.scheduleRender();
} }
@@ -640,21 +640,21 @@ export class VirtualScroller {
// Remove event listeners // Remove event listeners
this.scrollContainer.removeEventListener('scroll', this.scrollHandler); this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler);
// Clean up the resize observer if present // Clean up the resize observer if present
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
} }
// Remove rendered elements // Remove rendered elements
this.clearRenderedItems(); this.clearRenderedItems();
// Remove spacer // Remove spacer
this.spacerElement.remove(); this.spacerElement.remove();
// Remove virtual scroll class // Remove virtual scroll class
this.gridElement.classList.remove('virtual-scroll'); this.gridElement.classList.remove('virtual-scroll');
// Clear any pending timeout // Clear any pending timeout
this.clearLoadingTimeout(); this.clearLoadingTimeout();
} }
@@ -663,19 +663,19 @@ export class VirtualScroller {
showNoItemsPlaceholder(message) { showNoItemsPlaceholder(message) {
// Remove any existing placeholder first // Remove any existing placeholder first
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
// Create placeholder message // Create placeholder message
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'placeholder-message'; placeholder.className = 'placeholder-message';
// Determine appropriate message based on page type // Determine appropriate message based on page type
let placeholderText = ''; let placeholderText = '';
if (message) { if (message) {
placeholderText = message; placeholderText = message;
} else { } else {
const pageType = state.currentPageType; const pageType = state.currentPageType;
if (pageType === 'recipes') { if (pageType === 'recipes') {
placeholderText = ` placeholderText = `
<p>No recipes found</p> <p>No recipes found</p>
@@ -698,10 +698,10 @@ export class VirtualScroller {
`; `;
} }
} }
placeholder.innerHTML = placeholderText; placeholder.innerHTML = placeholderText;
placeholder.id = 'virtualScrollPlaceholder'; placeholder.id = 'virtualScrollPlaceholder';
// Append placeholder to the grid // Append placeholder to the grid
this.gridElement.appendChild(placeholder); this.gridElement.appendChild(placeholder);
} }
@@ -716,7 +716,7 @@ export class VirtualScroller {
// Utility method for debouncing // Utility method for debouncing
debounce(func, wait) { debounce(func, wait) {
let timeout; let timeout;
return function(...args) { return function (...args) {
const context = this; const context = this;
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait); timeout = setTimeout(() => func.apply(context, args), wait);
@@ -727,55 +727,55 @@ export class VirtualScroller {
disable() { disable() {
// Detach scroll event listener // Detach scroll event listener
this.scrollContainer.removeEventListener('scroll', this.scrollHandler); this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
// Clear all rendered items from the DOM // Clear all rendered items from the DOM
this.clearRenderedItems(); this.clearRenderedItems();
// Hide the spacer element // Hide the spacer element
if (this.spacerElement) { if (this.spacerElement) {
this.spacerElement.style.display = 'none'; this.spacerElement.style.display = 'none';
} }
// Flag as disabled // Flag as disabled
this.disabled = true; this.disabled = true;
console.log('Virtual scroller disabled'); console.log('Virtual scroller disabled');
} }
// Add enable method to resume rendering and events // Add enable method to resume rendering and events
enable() { enable() {
if (!this.disabled) return; if (!this.disabled) return;
// Reattach scroll event listener // Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler); this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Check if spacer element exists in the DOM, if not, recreate it // Check if spacer element exists in the DOM, if not, recreate it
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) { if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
console.log('Spacer element not found in DOM, recreating it'); console.log('Spacer element not found in DOM, recreating it');
// Create a new spacer element // Create a new spacer element
this.spacerElement = document.createElement('div'); this.spacerElement = document.createElement('div');
this.spacerElement.className = 'virtual-scroll-spacer'; this.spacerElement.className = 'virtual-scroll-spacer';
this.spacerElement.style.width = '100%'; this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; this.spacerElement.style.height = '0px';
this.spacerElement.style.pointerEvents = 'none'; this.spacerElement.style.pointerEvents = 'none';
// Append it to the grid // Append it to the grid
this.gridElement.appendChild(this.spacerElement); this.gridElement.appendChild(this.spacerElement);
// Update the spacer height // Update the spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
} else { } else {
// Show the spacer element if it exists // Show the spacer element if it exists
this.spacerElement.style.display = 'block'; this.spacerElement.style.display = 'block';
} }
// Flag as enabled // Flag as enabled
this.disabled = false; this.disabled = false;
// Re-render items // Re-render items
this.scheduleRender(); this.scheduleRender();
console.log('Virtual scroller enabled'); console.log('Virtual scroller enabled');
} }
@@ -783,31 +783,30 @@ export class VirtualScroller {
deepMerge(target, source) { deepMerge(target, source) {
if (!source || !target) return target; if (!source || !target) return target;
// Initialize result with a copy of target
const result = { ...target }; const result = { ...target };
// Only iterate over keys that exist in target if (!source) return result;
Object.keys(target).forEach(key => {
// Check if source has this key
if (source.hasOwnProperty(key)) {
const targetValue = target[key];
const sourceValue = source[key];
// If both values are non-null objects and not arrays, merge recursively // Iterate over all keys in the source object
if ( Object.keys(source).forEach(key => {
targetValue !== null && const targetValue = target[key];
typeof targetValue === 'object' && const sourceValue = source[key];
!Array.isArray(targetValue) &&
sourceValue !== null && // If both values are non-null objects and not arrays, merge recursively
typeof sourceValue === 'object' && if (
!Array.isArray(sourceValue) targetValue !== null &&
) { typeof targetValue === 'object' &&
result[key] = this.deepMerge(targetValue, sourceValue); !Array.isArray(targetValue) &&
} else { sourceValue !== null &&
// For primitive types, arrays, or null, use the value from source typeof sourceValue === 'object' &&
result[key] = sourceValue; !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; return result;
@@ -828,43 +827,43 @@ export class VirtualScroller {
// Update the item data using deep merge // Update the item data using deep merge
this.items[index] = this.deepMerge(this.items[index], updatedItem); this.items[index] = this.deepMerge(this.items[index], updatedItem);
// If the item is currently rendered, update its DOM representation // If the item is currently rendered, update its DOM representation
if (this.renderedItems.has(index)) { if (this.renderedItems.has(index)) {
const element = this.renderedItems.get(index); const element = this.renderedItems.get(index);
// Remove the old element // Remove the old element
element.remove(); element.remove();
this.renderedItems.delete(index); this.renderedItems.delete(index);
// Create and render the updated element // Create and render the updated element
const updatedElement = this.createItemElement(this.items[index], index); const updatedElement = this.createItemElement(this.items[index], index);
// Add update indicator visual effects // Add update indicator visual effects
updatedElement.classList.add('updated'); updatedElement.classList.add('updated');
// Add temporary update tag // Add temporary update tag
const updateIndicator = document.createElement('div'); const updateIndicator = document.createElement('div');
updateIndicator.className = 'update-indicator'; updateIndicator.className = 'update-indicator';
updateIndicator.textContent = 'Updated'; updateIndicator.textContent = 'Updated';
updatedElement.querySelector('.card-preview').appendChild(updateIndicator); updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
// Automatically remove the updated class after animation completes // Automatically remove the updated class after animation completes
setTimeout(() => { setTimeout(() => {
updatedElement.classList.remove('updated'); updatedElement.classList.remove('updated');
}, 1500); }, 1500);
// Automatically remove the indicator after animation completes // Automatically remove the indicator after animation completes
setTimeout(() => { setTimeout(() => {
if (updateIndicator && updateIndicator.parentNode) { if (updateIndicator && updateIndicator.parentNode) {
updateIndicator.remove(); updateIndicator.remove();
} }
}, 2000); }, 2000);
this.renderedItems.set(index, updatedElement); this.renderedItems.set(index, updatedElement);
this.gridElement.appendChild(updatedElement); this.gridElement.appendChild(updatedElement);
} }
return true; return true;
} }
@@ -882,26 +881,26 @@ export class VirtualScroller {
// Remove the item from the data array // Remove the item from the data array
this.items.splice(index, 1); this.items.splice(index, 1);
// Decrement total count // Decrement total count
this.totalItems = Math.max(0, this.totalItems - 1); this.totalItems = Math.max(0, this.totalItems - 1);
// Remove the item from rendered items if it exists // Remove the item from rendered items if it exists
if (this.renderedItems.has(index)) { if (this.renderedItems.has(index)) {
this.renderedItems.get(index).remove(); this.renderedItems.get(index).remove();
this.renderedItems.delete(index); this.renderedItems.delete(index);
} }
// Shift all rendered items with higher indices down by 1 // Shift all rendered items with higher indices down by 1
const indicesToUpdate = []; const indicesToUpdate = [];
// Collect all indices that need to be updated // Collect all indices that need to be updated
for (const [idx, element] of this.renderedItems.entries()) { for (const [idx, element] of this.renderedItems.entries()) {
if (idx > index) { if (idx > index) {
indicesToUpdate.push(idx); indicesToUpdate.push(idx);
} }
} }
// Update the elements and map entries // Update the elements and map entries
for (const idx of indicesToUpdate) { for (const idx of indicesToUpdate) {
const element = this.renderedItems.get(idx); const element = this.renderedItems.get(idx);
@@ -909,14 +908,14 @@ export class VirtualScroller {
// The item is now at the previous index // The item is now at the previous index
this.renderedItems.set(idx - 1, element); this.renderedItems.set(idx - 1, element);
} }
// Update the spacer height to reflect the new total // Update the spacer height to reflect the new total
this.updateSpacerHeight(); this.updateSpacerHeight();
// Re-render to ensure proper layout // Re-render to ensure proper layout
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
console.log(`Removed item with file path ${filePath} from virtual scroller data`); console.log(`Removed item with file path ${filePath} from virtual scroller data`);
return true; return true;
} }
@@ -929,28 +928,28 @@ export class VirtualScroller {
return; // Ignore rapid repeated triggers return; // Ignore rapid repeated triggers
} }
this.lastPageNavTime = now; this.lastPageNavTime = now;
const scrollContainer = this.scrollContainer; const scrollContainer = this.scrollContainer;
const viewportHeight = scrollContainer.clientHeight; const viewportHeight = scrollContainer.clientHeight;
// Calculate scroll distance (one viewport minus 10% overlap for context) // Calculate scroll distance (one viewport minus 10% overlap for context)
const scrollDistance = viewportHeight * 0.9; const scrollDistance = viewportHeight * 0.9;
// Determine the new scroll position // Determine the new scroll position
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance); const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
// Remove any existing transition indicators // Remove any existing transition indicators
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Scroll to the new position with smooth animation // Scroll to the new position with smooth animation
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: newScrollTop, top: newScrollTop,
behavior: 'smooth' behavior: 'smooth'
}); });
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
// Force render after scrolling // Force render after scrolling
setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 100);
setTimeout(() => this.renderItems(), 300); setTimeout(() => this.renderItems(), 300);
@@ -966,25 +965,25 @@ export class VirtualScroller {
scrollToTop() { scrollToTop() {
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
this.scrollContainer.scrollTo({ this.scrollContainer.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
// Force render after scrolling // Force render after scrolling
setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 100);
} }
scrollToBottom() { scrollToBottom() {
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
// Start loading all remaining pages to ensure content is available // Start loading all remaining pages to ensure content is available
this.loadRemainingPages().then(() => { this.loadRemainingPages().then(() => {
// After loading all content, scroll to the very bottom // After loading all content, scroll to the very bottom
@@ -995,27 +994,27 @@ export class VirtualScroller {
}); });
}); });
} }
// New method to load all remaining pages // New method to load all remaining pages
async loadRemainingPages() { async loadRemainingPages() {
// If we're already at the end or loading, don't proceed // If we're already at the end or loading, don't proceed
if (!this.hasMore || this.isLoading) return; if (!this.hasMore || this.isLoading) return;
console.log('Loading all remaining pages for End key navigation...'); console.log('Loading all remaining pages for End key navigation...');
// Keep loading pages until we reach the end // Keep loading pages until we reach the end
while (this.hasMore && !this.isLoading) { while (this.hasMore && !this.isLoading) {
await this.loadMoreItems(); await this.loadMoreItems();
// Force render after each page load // Force render after each page load
this.renderItems(); this.renderItems();
// Small delay to prevent overwhelming the browser // Small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
} }
console.log('Finished loading all pages'); console.log('Finished loading all pages');
// Final render to ensure all content is displayed // Final render to ensure all content is displayed
this.renderItems(); this.renderItems();
} }

View File

@@ -59,6 +59,12 @@
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ <button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
t('loras.controls.duplicates.action') }}</button> t('loras.controls.duplicates.action') }}</button>
</div> </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') }}"> <div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" 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> <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()) persisted2 = json.loads(recipe2_path.read_text())
assert persisted2["loras"][0]["file_name"] == "other_lora" assert persisted2["loras"][0]["file_name"] == "other_lora"
# Check cache
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id) cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
assert cached1["loras"][0]["file_name"] == new_name 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