From db7f09797b4b1b818683f49f6e99f0582ceb37e8 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 23 Dec 2025 10:07:09 +0800 Subject: [PATCH] feat: Introduce recipe favoriting with star icon toggle and filter options. --- locales/de.json | 8 +- locales/en.json | 6 +- locales/es.json | 8 +- locales/fr.json | 8 +- locales/he.json | 54 +-- locales/ja.json | 8 +- locales/ko.json | 8 +- locales/ru.json | 8 +- locales/zh-CN.json | 6 +- locales/zh-TW.json | 8 +- py/routes/handlers/recipe_handlers.py | 3 + py/services/recipe_scanner.py | 7 + py/services/recipes/persistence_service.py | 4 +- scripts/sync_translation_keys.py | 2 +- static/js/api/recipeApi.js | 72 ++-- static/js/components/RecipeCard.js | 78 ++++- static/js/recipes.js | 93 +++--- static/js/utils/VirtualScroller.js | 361 ++++++++++----------- templates/recipes.html | 6 + tests/services/test_recipe_scanner.py | 45 ++- 20 files changed, 486 insertions(+), 307 deletions(-) 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 @@
+
+ +