diff --git a/locales/de.json b/locales/de.json
index 94826973..a3e6c0a3 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -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",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/en.json b/locales/en.json
index 3345567f..6c74910d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -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",
diff --git a/locales/es.json b/locales/es.json
index d05018a5..c5fec9b2 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -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",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/fr.json b/locales/fr.json
index 1d36be36..b3ea3a71 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -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",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/he.json b/locales/he.json
index f7dace98..6593f53f 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -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} קבוצות כפולות",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/ja.json b/locales/ja.json
index 336cc856..39cd96d7 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -591,7 +591,11 @@
"refresh": {
"title": "レシピリストを更新"
},
- "filteredByLora": "LoRAでフィルタ済み"
+ "filteredByLora": "LoRAでフィルタ済み",
+ "favorites": {
+ "title": "[TODO: Translate] Show Favorites Only",
+ "action": "[TODO: Translate] Favorites"
+ }
},
"duplicates": {
"found": "{count} 個の重複グループが見つかりました",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/ko.json b/locales/ko.json
index 262b5c30..de949e24 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -591,7 +591,11 @@
"refresh": {
"title": "레시피 목록 새로고침"
},
- "filteredByLora": "LoRA로 필터링됨"
+ "filteredByLora": "LoRA로 필터링됨",
+ "favorites": {
+ "title": "[TODO: Translate] Show Favorites Only",
+ "action": "[TODO: Translate] Favorites"
+ }
},
"duplicates": {
"found": "{count}개의 중복 그룹 발견",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/ru.json b/locales/ru.json
index 851bccfa..df091b78 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -591,7 +591,11 @@
"refresh": {
"title": "Обновить список рецептов"
},
- "filteredByLora": "Фильтр по LoRA"
+ "filteredByLora": "Фильтр по LoRA",
+ "favorites": {
+ "title": "[TODO: Translate] Show Favorites Only",
+ "action": "[TODO: Translate] Favorites"
+ }
},
"duplicates": {
"found": "Найдено {count} групп дубликатов",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 2d9c5295..1a5d9192 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -591,7 +591,11 @@
"refresh": {
"title": "刷新配方列表"
},
- "filteredByLora": "按 LoRA 筛选"
+ "filteredByLora": "按 LoRA 筛选",
+ "favorites": {
+ "title": "仅显示收藏",
+ "action": "收藏"
+ }
},
"duplicates": {
"found": "发现 {count} 个重复组",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index e6a1967b..422cf582 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -591,7 +591,11 @@
"refresh": {
"title": "重新整理配方列表"
},
- "filteredByLora": "已依 LoRA 篩選"
+ "filteredByLora": "已依 LoRA 篩選",
+ "favorites": {
+ "title": "[TODO: Translate] Show Favorites Only",
+ "action": "[TODO: Translate] Favorites"
+ }
},
"duplicates": {
"found": "發現 {count} 組重複項",
@@ -1480,4 +1484,4 @@
"learnMore": "LM Civitai Extension Tutorial"
}
}
-}
\ No newline at end of file
+}
diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py
index 669d5d42..dc4ed239 100644
--- a/py/routes/handlers/recipe_handlers.py
+++ b/py/routes/handlers/recipe_handlers.py
@@ -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:
diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py
index 1ffb30b3..e8652c00 100644
--- a/py/services/recipe_scanner.py
+++ b/py/services/recipe_scanner.py
@@ -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']
diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py
index be8b3777..e1c7ae15 100644
--- a/py/services/recipes/persistence_service.py
+++ b/py/services/recipes/persistence_service.py
@@ -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)
diff --git a/scripts/sync_translation_keys.py b/scripts/sync_translation_keys.py
index 8a0edb66..5ae1b71d 100644
--- a/scripts/sync_translation_keys.py
+++ b/scripts/sync_translation_keys.py
@@ -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."""
diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js
index 33deea7e..b12cf005 100644
--- a/static/js/api/recipeApi.js
+++ b/static/js/api/recipeApi.js
@@ -39,7 +39,7 @@ export function extractRecipeId(filePath) {
*/
export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState();
-
+
try {
const params = new URLSearchParams({
page: page,
@@ -47,24 +47,28 @@ 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);
} else if (pageState.searchOptions?.recursive !== undefined) {
params.append('recursive', pageState.searchOptions.recursive);
}
-
+
// If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
-
+
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
}
-
+
const recipe = await response.json();
-
+
// Return in expected format
return {
items: [recipe],
@@ -74,18 +78,18 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
hasMore: false
};
}
-
+
// Add custom filter for Lora if present
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true');
} else {
// Normal filtering logic
-
+
// Add search filter if present
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
-
+
// Add search option parameters
if (pageState.searchOptions) {
params.append('search_title', pageState.searchOptions.title.toString());
@@ -95,12 +99,12 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
params.append('fuzzy', 'true');
}
}
-
+
// Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
params.append('base_models', pageState.filters.baseModel.join(','));
}
-
+
// Add tag filters
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -115,13 +119,13 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// Fetch recipes
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
-
+
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
}
-
+
const data = await response.json();
-
+
return {
items: data.items,
totalItems: data.total,
@@ -147,29 +151,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
updateFolders = false,
fetchPageFunction
} = options;
-
+
const pageState = getCurrentPageState();
-
+
try {
pageState.isLoading = true;
-
+
// Reset page counter
pageState.currentPage = 1;
-
+
// Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50);
-
+
// Update the virtual scroller
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
-
+
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2
-
+
return result;
} catch (error) {
console.error(`Error reloading ${modelType}s:`, error);
@@ -192,32 +196,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
updateFolders = false,
fetchPageFunction
} = options;
-
+
const pageState = getCurrentPageState();
-
+
try {
// Start loading state
pageState.isLoading = true;
-
+
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
}
-
+
// Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
-
+
// Update virtual scroller with the new data
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
-
+
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2
-
+
return result;
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
@@ -247,18 +251,18 @@ export async function resetAndReload(updateFolders = false) {
export async function refreshRecipes() {
try {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
-
+
// Call the API endpoint to rebuild the recipe cache
const response = await fetch(RECIPE_ENDPOINTS.scan);
-
+
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache');
}
-
+
// After successful cache rebuild, reload the recipes
await resetAndReload();
-
+
showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
@@ -276,7 +280,7 @@ export async function refreshRecipes() {
*/
export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState();
-
+
// Use virtual scroller if available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
@@ -317,7 +321,7 @@ export async function updateRecipeMetadata(filePath, updates) {
if (!recipeId) {
throw new Error('Unable to determine recipe ID');
}
-
+
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT',
headers: {
@@ -334,7 +338,7 @@ export async function updateRecipeMetadata(filePath, updates) {
}
state.virtualScroller.updateSingleItem(filePath, updates);
-
+
return data;
} catch (error) {
console.error('Error updating recipe:', error);
diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js
index 13ef4b5d..f89a2425 100644
--- a/static/js/components/RecipeCard.js
+++ b/static/js/components/RecipeCard.js
@@ -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 {
` : ''}
${baseModelDisplay}
+
@@ -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();
diff --git a/static/js/recipes.js b/static/js/recipes.js
index a535652e..1b495ad7 100644
--- a/static/js/recipes.js
+++ b/static/js/recipes.js
@@ -42,20 +42,20 @@ class RecipeManager {
// Page controls for shared sidebar behaviors
this.pageControls = new RecipePageControls();
-
+
// Initialize ImportManager
this.importManager = new ImportManager();
-
+
// Initialize RecipeModal
this.recipeModal = new RecipeModal();
-
+
// Initialize DuplicatesManager
this.duplicatesManager = new DuplicatesManager(this);
-
+
// Add state tracking for infinite scroll
this.pageState.isLoading = false;
this.pageState.hasMore = true;
-
+
// Custom filter state - move to pageState for compatibility with virtual scrolling
this.pageState.customFilter = {
active: false,
@@ -64,26 +64,26 @@ class RecipeManager {
recipeId: null
};
}
-
+
async initialize() {
// Initialize event listeners
this.initEventListeners();
-
+
// Set default search options if not already defined
this._initSearchOptions();
-
+
// Initialize context menu
new RecipeContextMenu();
-
+
// Check for custom filter parameters in session storage
this._checkCustomFilter();
-
+
// Expose necessary functions to the page
this._exposeGlobalFunctions();
// Initialize sidebar navigation
await this._initSidebar();
-
+
// Initialize common page features
appCore.initializePageFeatures();
}
@@ -97,7 +97,7 @@ class RecipeManager {
console.error('Failed to initialize recipe sidebar:', error);
}
}
-
+
_initSearchOptions() {
// Ensure recipes search options are properly initialized
if (!this.pageState.searchOptions) {
@@ -110,21 +110,21 @@ class RecipeManager {
};
}
}
-
+
_exposeGlobalFunctions() {
// Only expose what's needed for the page
window.recipeManager = this;
window.importManager = this.importManager;
}
-
+
_checkCustomFilter() {
// Check for Lora filter
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
-
+
// Check for specific recipe ID
const viewRecipeId = getSessionItem('viewRecipeId');
-
+
// Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) {
this.pageState.customFilter = {
@@ -133,35 +133,35 @@ class RecipeManager {
loraHash: filterLoraHash,
recipeId: viewRecipeId
};
-
+
// Show custom filter indicator
this._showCustomFilterIndicator();
}
}
-
+
_showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText');
-
+
if (!indicator || !textElement) return;
-
+
// Update text based on filter type
let filterText = '';
-
+
if (this.pageState.customFilter.recipeId) {
filterText = 'Viewing specific recipe';
} else if (this.pageState.customFilter.loraName) {
// Format with Lora name
const loraName = this.pageState.customFilter.loraName;
- const displayName = loraName.length > 25 ?
- loraName.substring(0, 22) + '...' :
+ const displayName = loraName.length > 25 ?
+ loraName.substring(0, 22) + '...' :
loraName;
-
+
filterText = `
Recipes using: ${displayName}`;
} else {
filterText = 'Filtered recipes';
}
-
+
// Update indicator text and show it
textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip
@@ -169,14 +169,14 @@ class RecipeManager {
textElement.setAttribute('title', this.pageState.customFilter.loraName);
}
indicator.classList.remove('hidden');
-
+
// Add pulse animation
const filterElement = indicator.querySelector('.filter-active');
if (filterElement) {
filterElement.classList.add('animate');
setTimeout(() => filterElement.classList.remove('animate'), 600);
}
-
+
// Add click handler for clear filter button
const clearFilterBtn = indicator.querySelector('.clear-filter');
if (clearFilterBtn) {
@@ -186,7 +186,7 @@ class RecipeManager {
});
}
}
-
+
_clearCustomFilter() {
// Reset custom filter
this.pageState.customFilter = {
@@ -195,22 +195,22 @@ class RecipeManager {
loraHash: null,
recipeId: null
};
-
+
// Hide indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
-
+
// Clear any session storage items
removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId');
-
+
// Reset and refresh the virtual scroller
refreshVirtualScroll();
}
-
+
initEventListeners() {
// Sort select
const sortSelect = document.getElementById('sortSelect');
@@ -225,8 +225,17 @@ 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
async loadRecipes(resetPage = true) {
// Skip loading if in duplicates mode
@@ -234,32 +243,32 @@ class RecipeManager {
if (pageState.duplicatesMode) {
return;
}
-
+
if (resetPage) {
refreshVirtualScroll();
}
}
-
+
/**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/
async refreshRecipes() {
return refreshRecipes();
}
-
+
showRecipeDetails(recipe) {
this.recipeModal.showRecipeDetails(recipe);
}
-
+
// Duplicate detection and management methods
async findDuplicateRecipes() {
return await this.duplicatesManager.findDuplicates();
}
-
+
selectLatestDuplicates() {
this.duplicatesManager.selectLatestDuplicates();
}
-
+
deleteSelectedDuplicates() {
this.duplicatesManager.deleteSelectedDuplicates();
}
@@ -267,14 +276,14 @@ class RecipeManager {
confirmDeleteDuplicates() {
this.duplicatesManager.confirmDeleteDuplicates();
}
-
+
exitDuplicateMode() {
// Clear the grid first to prevent showing old content temporarily
const recipeGrid = document.getElementById('recipeGrid');
if (recipeGrid) {
recipeGrid.innerHTML = '';
}
-
+
this.duplicatesManager.exitDuplicateMode();
}
}
@@ -283,7 +292,7 @@ class RecipeManager {
document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application
await appCore.initialize();
-
+
// Initialize recipe manager
const recipeManager = new RecipeManager();
await recipeManager.initialize();
diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js
index 9fd37bfe..59630f57 100644
--- a/static/js/utils/VirtualScroller.js
+++ b/static/js/utils/VirtualScroller.js
@@ -12,13 +12,13 @@ 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
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
-
+
// Add data windowing enable/disable flag
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
@@ -73,15 +73,15 @@ export class VirtualScroller {
this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
this.spacerElement.style.pointerEvents = 'none';
-
+
// The grid will be used for the actual visible items
this.gridElement.style.position = 'relative';
this.gridElement.style.minHeight = '0';
-
+
// Apply padding directly to ensure consistency
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
-
+
// Place the spacer inside the grid container
this.gridElement.appendChild(this.spacerElement);
}
@@ -97,16 +97,16 @@ export class VirtualScroller {
const containerStyle = getComputedStyle(this.containerElement);
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
-
+
// Calculate available content width (excluding padding)
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
-
+
// Get display density setting
const displayDensity = state.global.settings?.display_density || 'default';
-
+
// Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth;
-
+
// Match exact column counts and CSS container width values based on density
if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') {
@@ -137,17 +137,17 @@ export class VirtualScroller {
}
maxGridWidth = 1400; // Match exact CSS container width for 1080p
}
-
+
// Calculate baseCardWidth based on desired column count and available space
// Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
-
+
// Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
-
+
// Set exact column count based on screen size and mode
this.columnsCount = maxColumns;
-
+
// When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space
@@ -155,30 +155,30 @@ export class VirtualScroller {
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
));
}
-
+
// Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
-
+
// Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio;
-
+
// Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Update grid element max-width to match available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
-
+
// Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
this.gridElement.classList.add(`${displayDensity}-density`);
-
+
// Update spacer height
this.updateSpacerHeight();
-
+
// Re-render with new layout
this.clearRenderedItems();
this.scheduleRender();
-
+
return true;
}
@@ -186,20 +186,20 @@ export class VirtualScroller {
// Debounced scroll handler
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
-
+
// Window resize handler for layout recalculation
this.resizeHandler = this.debounce(() => {
this.calculateLayout();
}, 150);
-
+
window.addEventListener('resize', this.resizeHandler);
-
+
// Use ResizeObserver for more accurate container size detection
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(this.debounce(() => {
this.calculateLayout();
}, 150));
-
+
this.resizeObserver.observe(this.containerElement);
}
}
@@ -217,35 +217,35 @@ export class VirtualScroller {
async loadInitialBatch() {
const pageState = getCurrentPageState();
if (this.isLoading) return;
-
+
this.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety
-
+
try {
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
-
+
// Initialize the data window with the first batch of items
this.items = items || [];
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
this.dataWindow = { start: 0, end: this.items.length };
this.absoluteWindowStart = 0;
-
+
// Update the spacer height based on the total number of items
this.updateSpacerHeight();
-
+
// Check if there are no items and show placeholder if needed
if (this.items.length === 0) {
this.showNoItemsPlaceholder();
} else {
this.removeNoItemsPlaceholder();
}
-
+
// Reset page state to sync with our virtual scroller
pageState.currentPage = 2; // Next page to load would be 2
pageState.hasMore = this.hasMore;
pageState.isLoading = false;
-
+
return { items, totalItems, hasMore };
} catch (err) {
console.error('Failed to load initial batch:', err);
@@ -260,36 +260,36 @@ export class VirtualScroller {
async loadMoreItems() {
const pageState = getCurrentPageState();
if (this.isLoading || !this.hasMore) return;
-
+
this.isLoading = true;
pageState.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety
-
+
try {
console.log('Loading more items, page:', pageState.currentPage);
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
-
+
if (items && items.length > 0) {
this.items = [...this.items, ...items];
this.hasMore = hasMore;
pageState.hasMore = hasMore;
-
+
// Update page for next request
pageState.currentPage++;
-
+
// Update the spacer height
this.updateSpacerHeight();
-
+
// Render the newly loaded items if they're in view
this.scheduleRender();
-
+
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
} else {
this.hasMore = false;
pageState.hasMore = false;
console.log('No more items to load');
}
-
+
return items;
} catch (err) {
console.error('Failed to load more items:', err);
@@ -305,7 +305,7 @@ export class VirtualScroller {
setLoadingTimeout() {
// Clear any existing timeout first
this.clearLoadingTimeout();
-
+
// Set a new timeout to prevent loading state from getting stuck
this.loadingTimeout = setTimeout(() => {
if (this.isLoading) {
@@ -326,15 +326,15 @@ export class VirtualScroller {
updateSpacerHeight() {
if (this.columnsCount === 0) return;
-
+
// Calculate total rows needed based on total items and columns
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
// Add row gaps to the total height calculation
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
-
+
// Include container padding in the total height
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
-
+
// Update spacer height to represent all items
this.spacerElement.style.height = `${spacerHeight}px`;
}
@@ -342,28 +342,28 @@ export class VirtualScroller {
getVisibleRange() {
const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight;
-
+
// Calculate the visible row range, accounting for row gaps
const rowHeight = this.itemHeight + this.rowGap;
const startRow = Math.floor(scrollTop / rowHeight);
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
-
+
// Add overscan for smoother scrolling
const overscanRows = this.overscan;
const firstRow = Math.max(0, startRow - overscanRows);
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
-
+
// Calculate item indices
const firstIndex = firstRow * this.columnsCount;
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
-
+
return { start: firstIndex, end: lastIndex };
}
// Update the scheduleRender method to check for disabled state
scheduleRender() {
if (this.disabled || this.renderScheduled) return;
-
+
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderItems();
@@ -374,25 +374,25 @@ export class VirtualScroller {
// Update the renderItems method to check for disabled state
renderItems() {
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
-
+
const { start, end } = this.getVisibleRange();
-
+
// Check if render range has significantly changed
- const isSameRange =
- start >= this.lastRenderRange.start &&
+ const isSameRange =
+ start >= this.lastRenderRange.start &&
end <= this.lastRenderRange.end &&
Math.abs(start - this.lastRenderRange.start) < 10;
-
+
if (isSameRange) return;
-
+
this.lastRenderRange = { start, end };
-
+
// Determine which items need to be added and removed
const currentIndices = new Set();
for (let i = start; i < end && i < this.items.length; i++) {
currentIndices.add(i);
}
-
+
// Remove items that are no longer visible
for (const [index, element] of this.renderedItems.entries()) {
if (!currentIndices.has(index)) {
@@ -400,10 +400,10 @@ export class VirtualScroller {
this.renderedItems.delete(index);
}
}
-
+
// Use DocumentFragment for batch DOM operations
const fragment = document.createDocumentFragment();
-
+
// Add new visible items to the fragment
for (let i = start; i < end && i < this.items.length; i++) {
if (!this.renderedItems.has(i)) {
@@ -413,17 +413,17 @@ export class VirtualScroller {
this.renderedItems.set(i, element);
}
}
-
+
// Add the fragment to the grid (single DOM operation)
if (fragment.childNodes.length > 0) {
this.gridElement.appendChild(fragment);
}
-
+
// 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) {
this.loadMoreItems();
}
-
+
// Check if we need to slide the data window
this.slideDataWindow();
}
@@ -439,14 +439,14 @@ export class VirtualScroller {
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
this.updateSpacerHeight();
-
+
// Check if there are no items and show placeholder if needed
if (this.items.length === 0) {
this.showNoItemsPlaceholder();
} else {
this.removeNoItemsPlaceholder();
}
-
+
// Clear all rendered items and redraw
this.clearRenderedItems();
this.scheduleRender();
@@ -455,29 +455,29 @@ export class VirtualScroller {
createItemElement(item, index) {
// Create the DOM element
const element = this.createItemFn(item);
-
+
// Add virtual scroll item class
element.classList.add('virtual-scroll-item');
-
+
// Calculate the position
const row = Math.floor(index / this.columnsCount);
const col = index % this.columnsCount;
-
+
// Calculate precise positions with row gap included
// Add the top padding to account for container padding
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
-
+
// Position correctly with leftOffset (no need to add padding as absolute
// positioning is already relative to the padding edge of the container)
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
-
+
// Position the element with absolute positioning
element.style.position = 'absolute';
element.style.left = `${leftPos}px`;
element.style.top = `${topPos}px`;
element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`;
-
+
return element;
}
@@ -486,17 +486,17 @@ export class VirtualScroller {
const scrollTop = this.scrollContainer.scrollTop;
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
this.lastScrollTop = scrollTop;
-
+
// Handle large jumps in scroll position - check if we need to fetch a new window
const { scrollHeight } = this.scrollContainer;
const scrollRatio = scrollTop / scrollHeight;
-
+
// Only perform data windowing if the feature is enabled
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
const currentWindowStart = this.absoluteWindowStart;
const currentWindowEnd = currentWindowStart + this.items.length;
-
+
// If the estimated position is outside our current window by a significant amount
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
// 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
}
}
-
+
// Render visible items
this.scheduleRender();
-
+
// If we're near the bottom and have more items, load them
const { clientHeight } = this.scrollContainer;
const scrollBottom = scrollTop + clientHeight;
-
+
// Fix the threshold calculation - use percentage of remaining height instead
// We'll trigger loading when within 20% of the bottom of rendered content
const remainingScroll = scrollHeight - scrollBottom;
@@ -521,9 +521,9 @@ export class VirtualScroller {
// Or when within 2 rows of content from the bottom, whichever is larger
(this.itemHeight + this.rowGap) * 2
);
-
+
const shouldLoadMore = remainingScroll <= scrollThreshold;
-
+
if (shouldLoadMore && this.hasMore && !this.isLoading) {
this.loadMoreItems();
}
@@ -533,40 +533,40 @@ export class VirtualScroller {
async fetchDataWindow(targetIndex) {
// Skip if data windowing is disabled or already fetching
if (!this.enableDataWindowing || this.fetchingWindow) return;
-
+
this.fetchingWindow = true;
-
+
try {
// Calculate which page we need to fetch based on target index
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
-
+
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
-
+
if (items && items.length > 0) {
// Calculate new absolute window start
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
-
+
// Replace the entire data window with new items
this.items = items;
- this.dataWindow = {
+ this.dataWindow = {
start: 0,
end: items.length
};
-
+
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
-
+
// Update the current page for future fetches
const pageState = getCurrentPageState();
pageState.currentPage = targetPage + 1;
pageState.hasMore = hasMore;
-
+
// Update the spacer height and clear current rendered items
this.updateSpacerHeight();
this.clearRenderedItems();
this.scheduleRender();
-
+
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
}
} catch (err) {
@@ -581,37 +581,37 @@ export class VirtualScroller {
async slideDataWindow() {
// Skip if data windowing is disabled
if (!this.enableDataWindowing) return;
-
+
const { start, end } = this.getVisibleRange();
const windowStart = this.dataWindow.start;
const windowEnd = this.dataWindow.end;
const absoluteIndex = this.absoluteWindowStart + windowStart;
-
+
// Calculate the midpoint of the visible range
const visibleMidpoint = Math.floor((start + end) / 2);
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
-
+
// Check if we're too close to the window edges
const closeToStart = start - windowStart < this.windowPadding;
const closeToEnd = windowEnd - end < this.windowPadding;
-
+
// If we're close to either edge and have total items > window size
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
// Calculate a new target index centered around the current viewport
const halfWindow = Math.floor(this.windowSize / 2);
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
-
+
// Don't fetch a new window if we're already showing items near the beginning
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
return;
}
-
+
// 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) {
return;
}
-
+
// Fetch the new data window
await this.fetchDataWindow(targetIndex);
}
@@ -620,18 +620,18 @@ export class VirtualScroller {
reset() {
// Remove all rendered items
this.clearRenderedItems();
-
+
// Reset state
this.items = [];
this.totalItems = 0;
this.hasMore = true;
-
+
// Reset spacer height
this.spacerElement.style.height = '0px';
-
+
// Remove any placeholder
this.removeNoItemsPlaceholder();
-
+
// Schedule a re-render
this.scheduleRender();
}
@@ -640,21 +640,21 @@ export class VirtualScroller {
// Remove event listeners
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
window.removeEventListener('resize', this.resizeHandler);
-
+
// Clean up the resize observer if present
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
-
+
// Remove rendered elements
this.clearRenderedItems();
-
+
// Remove spacer
this.spacerElement.remove();
-
+
// Remove virtual scroll class
this.gridElement.classList.remove('virtual-scroll');
-
+
// Clear any pending timeout
this.clearLoadingTimeout();
}
@@ -663,19 +663,19 @@ export class VirtualScroller {
showNoItemsPlaceholder(message) {
// Remove any existing placeholder first
this.removeNoItemsPlaceholder();
-
+
// Create placeholder message
const placeholder = document.createElement('div');
placeholder.className = 'placeholder-message';
-
+
// Determine appropriate message based on page type
let placeholderText = '';
-
+
if (message) {
placeholderText = message;
} else {
const pageType = state.currentPageType;
-
+
if (pageType === 'recipes') {
placeholderText = `
No recipes found
@@ -698,10 +698,10 @@ export class VirtualScroller {
`;
}
}
-
+
placeholder.innerHTML = placeholderText;
placeholder.id = 'virtualScrollPlaceholder';
-
+
// Append placeholder to the grid
this.gridElement.appendChild(placeholder);
}
@@ -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);
@@ -727,55 +727,55 @@ export class VirtualScroller {
disable() {
// Detach scroll event listener
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
-
+
// Clear all rendered items from the DOM
this.clearRenderedItems();
-
+
// Hide the spacer element
if (this.spacerElement) {
this.spacerElement.style.display = 'none';
}
-
+
// Flag as disabled
this.disabled = true;
-
+
console.log('Virtual scroller disabled');
}
// Add enable method to resume rendering and events
enable() {
if (!this.disabled) return;
-
+
// Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
-
+
// Check if spacer element exists in the DOM, if not, recreate it
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
console.log('Spacer element not found in DOM, recreating it');
-
+
// Create a new spacer element
this.spacerElement = document.createElement('div');
this.spacerElement.className = 'virtual-scroll-spacer';
this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px';
this.spacerElement.style.pointerEvents = 'none';
-
+
// Append it to the grid
this.gridElement.appendChild(this.spacerElement);
-
+
// Update the spacer height
this.updateSpacerHeight();
} else {
// Show the spacer element if it exists
this.spacerElement.style.display = 'block';
}
-
+
// Flag as enabled
this.disabled = false;
-
+
// Re-render items
this.scheduleRender();
-
+
console.log('Virtual scroller enabled');
}
@@ -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;
@@ -828,43 +827,43 @@ export class VirtualScroller {
// Update the item data using deep merge
this.items[index] = this.deepMerge(this.items[index], updatedItem);
-
+
// If the item is currently rendered, update its DOM representation
if (this.renderedItems.has(index)) {
const element = this.renderedItems.get(index);
-
+
// Remove the old element
element.remove();
this.renderedItems.delete(index);
-
+
// Create and render the updated element
const updatedElement = this.createItemElement(this.items[index], index);
-
+
// Add update indicator visual effects
updatedElement.classList.add('updated');
-
+
// Add temporary update tag
const updateIndicator = document.createElement('div');
updateIndicator.className = 'update-indicator';
updateIndicator.textContent = 'Updated';
updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
-
+
// Automatically remove the updated class after animation completes
setTimeout(() => {
updatedElement.classList.remove('updated');
}, 1500);
-
+
// Automatically remove the indicator after animation completes
setTimeout(() => {
if (updateIndicator && updateIndicator.parentNode) {
updateIndicator.remove();
}
}, 2000);
-
+
this.renderedItems.set(index, updatedElement);
this.gridElement.appendChild(updatedElement);
}
-
+
return true;
}
@@ -882,26 +881,26 @@ export class VirtualScroller {
// Remove the item from the data array
this.items.splice(index, 1);
-
+
// Decrement total count
this.totalItems = Math.max(0, this.totalItems - 1);
-
+
// Remove the item from rendered items if it exists
if (this.renderedItems.has(index)) {
this.renderedItems.get(index).remove();
this.renderedItems.delete(index);
}
-
+
// Shift all rendered items with higher indices down by 1
const indicesToUpdate = [];
-
+
// Collect all indices that need to be updated
for (const [idx, element] of this.renderedItems.entries()) {
if (idx > index) {
indicesToUpdate.push(idx);
}
}
-
+
// Update the elements and map entries
for (const idx of indicesToUpdate) {
const element = this.renderedItems.get(idx);
@@ -909,14 +908,14 @@ export class VirtualScroller {
// The item is now at the previous index
this.renderedItems.set(idx - 1, element);
}
-
+
// Update the spacer height to reflect the new total
this.updateSpacerHeight();
-
+
// Re-render to ensure proper layout
this.clearRenderedItems();
this.scheduleRender();
-
+
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
return true;
}
@@ -929,28 +928,28 @@ export class VirtualScroller {
return; // Ignore rapid repeated triggers
}
this.lastPageNavTime = now;
-
+
const scrollContainer = this.scrollContainer;
const viewportHeight = scrollContainer.clientHeight;
-
+
// Calculate scroll distance (one viewport minus 10% overlap for context)
const scrollDistance = viewportHeight * 0.9;
-
+
// Determine the new scroll position
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
-
+
// Remove any existing transition indicators
this.removeExistingTransitionIndicator();
-
+
// Scroll to the new position with smooth animation
scrollContainer.scrollTo({
top: newScrollTop,
behavior: 'smooth'
});
-
+
// Page transition indicator removed
// this.showTransitionIndicator();
-
+
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
setTimeout(() => this.renderItems(), 300);
@@ -966,25 +965,25 @@ export class VirtualScroller {
scrollToTop() {
this.removeExistingTransitionIndicator();
-
+
// Page transition indicator removed
// this.showTransitionIndicator();
-
+
this.scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
-
+
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
}
scrollToBottom() {
this.removeExistingTransitionIndicator();
-
+
// Page transition indicator removed
// this.showTransitionIndicator();
-
+
// Start loading all remaining pages to ensure content is available
this.loadRemainingPages().then(() => {
// After loading all content, scroll to the very bottom
@@ -995,27 +994,27 @@ export class VirtualScroller {
});
});
}
-
+
// New method to load all remaining pages
async loadRemainingPages() {
// If we're already at the end or loading, don't proceed
if (!this.hasMore || this.isLoading) return;
-
+
console.log('Loading all remaining pages for End key navigation...');
-
+
// Keep loading pages until we reach the end
while (this.hasMore && !this.isLoading) {
await this.loadMoreItems();
-
+
// Force render after each page load
this.renderItems();
-
+
// Small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50));
}
-
+
console.log('Finished loading all pages');
-
+
// Final render to ensure all content is displayed
this.renderItems();
}
diff --git a/templates/recipes.html b/templates/recipes.html
index 5f4defd9..e41d80b1 100644
--- a/templates/recipes.html
+++ b/templates/recipes.html
@@ -59,6 +59,12 @@
+
+
+