Merge branch 'sort-by-usage-count' into main

This commit is contained in:
pixelpaws
2025-12-26 22:17:03 +08:00
committed by GitHub
85 changed files with 6030 additions and 1550 deletions

View File

@@ -1,8 +1,10 @@
html, body {
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* Disable default scrolling */
overflow: hidden;
/* Disable default scrolling */
}
/* 针对Firefox */
@@ -58,12 +60,12 @@ html, body {
--badge-update-bg: oklch(72% 0.2 220);
--badge-update-text: oklch(28% 0.03 220);
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
/* Spacing Scale */
--space-1: calc(8px * 1);
--space-2: calc(8px * 2);
--space-3: calc(8px * 3);
/* Z-index Scale */
--z-base: 10;
--z-header: 100;
@@ -75,8 +77,9 @@ html, body {
--border-radius-sm: 8px;
--border-radius-xs: 4px;
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
--scrollbar-width: 8px;
/* 添加滚动条宽度变量 */
/* Shortcut styles */
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
@@ -104,7 +107,8 @@ html[data-theme="light"] {
--lora-surface: oklch(25% 0.02 256 / 0.98);
--lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(98% 0.02 256);
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
--lora-warning: oklch(75% 0.25 80);
/* Modified to be used with oklch() */
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
--badge-update-bg: oklch(62% 0.18 220);
@@ -118,5 +122,10 @@ body {
color: var(--text-color);
display: flex;
flex-direction: column;
padding-top: 0; /* Remove the padding-top */
padding-top: 0;
/* Remove the padding-top */
}
.hidden {
display: none !important;
}

View File

@@ -1,7 +1,8 @@
/* Import Modal Styles */
.import-step {
margin: var(--space-2) 0;
transition: none !important; /* Disable any transitions that might affect display */
transition: none !important;
/* Disable any transitions that might affect display */
}
/* Import Mode Toggle */
@@ -107,7 +108,8 @@
justify-content: center;
}
.recipe-image img {
.recipe-image img,
.recipe-preview-video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -379,7 +381,7 @@
.recipe-details-layout {
grid-template-columns: 1fr;
}
.recipe-image-container {
height: 150px;
}
@@ -512,14 +514,17 @@
/* Prevent layout shift with scrollbar */
.modal-content {
overflow-y: scroll; /* Always show scrollbar */
scrollbar-gutter: stable; /* Reserve space for scrollbar */
overflow-y: scroll;
/* Always show scrollbar */
scrollbar-gutter: stable;
/* Reserve space for scrollbar */
}
/* For browsers that don't support scrollbar-gutter */
@supports not (scrollbar-gutter: stable) {
.modal-content {
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */
padding-right: calc(var(--space-2) + var(--scrollbar-width));
/* Add extra padding for scrollbar */
}
}
@@ -586,7 +591,8 @@
/* Remove the old warning-message styles that were causing layout issues */
.warning-message {
display: none; /* Hide the old style */
display: none;
/* Hide the old style */
}
/* Update deleted badge to be more prominent */
@@ -613,7 +619,8 @@
color: var(--lora-error);
font-size: 0.9em;
margin-top: 8px;
min-height: 20px; /* Ensure there's always space for the error message */
min-height: 20px;
/* Ensure there's always space for the error message */
font-weight: 500;
}
@@ -662,8 +669,15 @@
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.duplicate-warning {
@@ -779,6 +793,7 @@
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -793,9 +808,9 @@
opacity: 0.8;
}
.duplicate-recipe-date,
.duplicate-recipe-date,
.duplicate-recipe-lora-count {
display: flex;
align-items: center;
gap: 4px;
}
}

View File

@@ -20,7 +20,7 @@
}
.modal-header-row {
width: 85%;
width: 84%;
display: flex;
align-items: flex-start;
gap: var(--space-2);

View File

@@ -122,6 +122,7 @@ body.modal-open {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
z-index: 10;
}
.close:hover {

View File

@@ -242,6 +242,20 @@
border-color: var(--lora-error-border);
}
/* Subtle styling for special system tags like "No tags" */
.filter-tag.special-tag {
border-style: dashed;
opacity: 0.8;
font-style: italic;
}
/* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active,
.filter-tag.special-tag.exclude {
border-style: solid;
opacity: 1;
}
/* Tag filter styles */
.tag-filter {
display: flex;

View File

@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
detail: '/api/lm/recipe',
scan: '/api/lm/recipes/scan',
update: '/api/lm/recipe',
roots: '/api/lm/recipes/roots',
folders: '/api/lm/recipes/folders',
folderTree: '/api/lm/recipes/folder-tree',
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
};
const RECIPE_SIDEBAR_CONFIG = {
config: {
displayName: 'Recipe',
supportsMove: true,
},
endpoints: RECIPE_ENDPOINTS,
};
export function extractRecipeId(filePath) {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
}
/**
* Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch
@@ -10,25 +39,36 @@ import { showToast } from '../utils/uiHelpers.js';
*/
export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState();
try {
const params = new URLSearchParams({
page: page,
page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy
});
if (pageState.showFavoritesOnly) {
params.append('favorite', 'true');
}
if (pageState.activeFolder !== null && pageState.activeFolder !== undefined) {
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(`/api/lm/recipe/${pageState.customFilter.recipeId}`);
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],
@@ -38,33 +78,34 @@ 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());
params.append('search_tags', pageState.searchOptions.tags.toString());
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
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]) => {
@@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
}
// Fetch recipes
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
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,
@@ -111,29 +152,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);
@@ -156,32 +197,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);
@@ -211,18 +252,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('/api/lm/recipes/scan');
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);
@@ -240,7 +281,7 @@ export async function refreshRecipes() {
*/
export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState();
// Use virtual scroller if available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.showSimpleLoading('Saving metadata...');
// Extract recipeId from filePath (basename without extension)
const basename = filePath.split('/').pop().split('\\').pop();
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
const recipeId = extractRecipeId(filePath);
if (!recipeId) {
throw new Error('Unable to determine recipe ID');
}
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) {
}
state.virtualScroller.updateSingleItem(filePath, updates);
return data;
} catch (error) {
console.error('Error updating recipe:', error);
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.hide();
}
}
export class RecipeSidebarApiClient {
constructor() {
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
}
async fetchUnifiedFolderTree() {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error('Failed to fetch recipe folder tree');
}
return response.json();
}
async fetchModelRoots() {
const response = await fetch(this.apiConfig.endpoints.roots);
if (!response.ok) {
throw new Error('Failed to fetch recipe roots');
}
return response.json();
}
async fetchModelFolders() {
const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) {
throw new Error('Failed to fetch recipe folders');
}
return response.json();
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return [];
}
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
}
if (result.failure_count > 0) {
showToast(
'toast.api.bulkMovePartial',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
failureCount: result.failure_count,
},
'warning'
);
const failedFiles = (result.results || [])
.filter((item) => !item.success)
.map((item) => item.message || 'Unknown error');
if (failedFiles.length > 0) {
const failureMessage =
failedFiles.length <= 3
? failedFiles.join('\n')
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
}
} else {
showToast(
'toast.api.bulkMoveSuccess',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
},
'success'
);
}
return result.results || [];
}
async moveSingleModel(filePath, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null;
}
const recipeId = extractRecipeId(filePath);
if (!recipeId) {
showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error');
return null;
}
const response = await fetch(this.apiConfig.endpoints.move, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: recipeId,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`);
}
if (result.message) {
showToast('toast.api.moveInfo', { message: result.message }, 'info');
} else {
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
return {
original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path || filePath,
folder: result.folder || '',
message: result.message,
};
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
try {
state.loadingManager?.showSimpleLoading('Deleting recipes...');
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to delete recipes');
}
return {
success: true,
deleted_count: result.total_deleted,
failed_count: result.total_failed || 0,
errors: result.failed || [],
};
} finally {
state.loadingManager?.hide();
}
}
}

View File

@@ -15,6 +15,29 @@ export class GlobalContextMenu extends BaseContextMenu {
showMenu(x, y, origin = null) {
const contextOrigin = origin || { type: 'global' };
// Conditional visibility for recipes page
const isRecipesPage = state.currentPageType === 'recipes';
const modelUpdateItem = this.menu.querySelector('[data-action="check-model-updates"]');
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden');
licenseRefreshItem?.classList.add('hidden');
downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden');
downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
}
super.showMenu(x, y, contextOrigin);
}
@@ -40,6 +63,11 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to refresh missing license metadata:', error);
});
break;
case 'repair-recipes':
this.repairRecipes(menuItem).catch((error) => {
console.error('Failed to repair recipes:', error);
});
break;
default:
console.warn(`Unhandled global context menu action: ${action}`);
break;
@@ -235,4 +263,78 @@ export class GlobalContextMenu extends BaseContextMenu {
return `${displayName}s`;
}
async repairRecipes(menuItem) {
if (this._repairInProgress) {
return;
}
this._repairInProgress = true;
menuItem?.classList.add('disabled');
const loadingMessage = translate(
'globalContextMenu.repairRecipes.loading',
{},
'Repairing recipe data...'
);
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
try {
const response = await fetch('/api/lm/recipes/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to start repair');
}
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
let isComplete = false;
while (!isComplete && this._repairInProgress) {
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
if (progressResponse.ok) {
const progressResult = await progressResponse.json();
if (progressResult.success && progressResult.progress) {
const p = progressResult.progress;
if (p.status === 'processing') {
const percent = (p.current / p.total) * 100;
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
} else if (p.status === 'completed') {
isComplete = true;
progressUI?.complete(translate(
'globalContextMenu.repairRecipes.success',
{ count: p.repaired },
`Repaired ${p.repaired} recipes.`
));
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
// Refresh recipes page if active
if (window.recipesPage) {
window.recipesPage.refresh();
}
} else if (p.status === 'error') {
throw new Error(p.error || 'Repair failed');
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
isComplete = true;
progressUI?.complete();
}
}
if (!isComplete) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.error('Recipe repair failed:', error);
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
} finally {
this._repairInProgress = false;
menuItem?.classList.remove('disabled');
}
}
}

View File

@@ -4,13 +4,14 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
super('recipeContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe';
this.initNSFWSelector();
}
@@ -24,20 +25,20 @@ export class RecipeContextMenu extends BaseContextMenu {
const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload();
}
showMenu(x, y, card) {
// Call the parent method first to handle basic positioning
super.showMenu(x, y, card);
// Get recipe data to check for missing LoRAs
const recipeId = card.dataset.id;
const missingLorasItem = this.menu.querySelector('.download-missing-item');
if (recipeId && missingLorasItem) {
// Check if this card has missing LoRAs
const loraCountElement = card.querySelector('.lora-count');
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
// Show/hide the download missing LoRAs option based on missing status
if (hasMissingLoras) {
missingLorasItem.style.display = 'flex';
@@ -46,7 +47,7 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
}
handleMenuAction(action) {
// First try to handle with common actions from ModelContextMenuMixin
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -55,8 +56,8 @@ export class RecipeContextMenu extends BaseContextMenu {
// Handle recipe-specific actions
const recipeId = this.currentCard.dataset.id;
switch(action) {
switch (action) {
case 'details':
// Show recipe details
this.currentCard.click();
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
// Share recipe
this.currentCard.querySelector('.fa-share-alt')?.click();
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'delete':
// Delete recipe
this.currentCard.querySelector('.fa-trash')?.click();
@@ -89,9 +93,13 @@ export class RecipeContextMenu extends BaseContextMenu {
// Download missing LoRAs
this.downloadMissingLoRAs(recipeId);
break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
}
}
// New method to copy recipe syntax to clipboard
copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id;
@@ -114,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
});
}
// New method to send recipe to workflow
sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id;
@@ -137,14 +145,14 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
});
}
// View all LoRAs in the recipe
viewRecipeLoRAs(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
return;
}
// First get the recipe details to access its LoRAs
fetch(`/api/lm/recipe/${recipeId}`)
.then(response => response.json())
@@ -154,17 +162,17 @@ export class RecipeContextMenu extends BaseContextMenu {
removeSessionItem('recipe_to_lora_filterLoraHashes');
removeSessionItem('filterRecipeName');
removeSessionItem('viewLoraDetail');
// Collect all hashes from the recipe's LoRAs
const loraHashes = recipe.loras
.filter(lora => lora.hash)
.map(lora => lora.hash.toLowerCase());
if (loraHashes.length > 0) {
// Store the LoRA hashes and recipe name in session storage
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
setSessionItem('filterRecipeName', recipe.title);
// Navigate to the LoRAs page
window.location.href = '/loras';
} else {
@@ -176,34 +184,34 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
});
}
// Download missing LoRAs
async downloadMissingLoRAs(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
return;
}
try {
// First get the recipe details
const response = await fetch(`/api/lm/recipe/${recipeId}`);
const recipe = await response.json();
// Get missing LoRAs
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
if (missingLoras.length === 0) {
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
return;
}
// Show loading toast
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
// Get version info for each missing LoRA
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
let endpoint;
// Determine which endpoint to use based on available data
if (lora.modelVersionId) {
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
@@ -213,52 +221,52 @@ export class RecipeContextMenu extends BaseContextMenu {
console.error("Missing both hash and modelVersionId for lora:", lora);
return null;
}
const versionResponse = await fetch(endpoint);
const versionInfo = await versionResponse.json();
// Return original lora data combined with version info
return {
...lora,
civitaiInfo: versionInfo
};
});
// Wait for all API calls to complete
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
// Filter out null values (failed requests)
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
if (validLoras.length === 0) {
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
return;
}
// Prepare data for import manager using the retrieved information
const recipeData = {
loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ?
const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null;
return {
// Basic lora info
name: civitaiInfo.model?.name || lora.name,
version: civitaiInfo.name || '',
strength: lora.strength || 1.0,
// Model identifiers
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
modelVersionId: civitaiInfo.id || lora.modelVersionId,
// Metadata
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
baseModel: civitaiInfo.baseModel || '',
downloadUrl: civitaiInfo.downloadUrl || '',
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
file_name: modelFile ? modelFile.name.split('.')[0] : '',
// Status flags
existsLocally: false,
isDeleted: civitaiInfo.error === "Model not found",
@@ -267,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
};
})
};
// Call ImportManager's download missing LoRAs method
window.importManager.downloadMissingLoras(recipeData, recipeId);
} catch (error) {
@@ -279,6 +287,38 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
}
// Repair recipe metadata
async repairRecipe(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
return;
}
try {
showToast('recipes.contextMenu.repair.starting', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success');
// Refresh the current card or reload
this.resetAndReload();
} else {
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
}
} else {
throw new Error(result.error || 'Repair failed');
}
} catch (error) {
console.error('Error repairing recipe:', error);
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
}
}
}
// Mix in shared methods from ModelContextMenuMixin

View File

@@ -1,8 +1,11 @@
// 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';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
class RecipeCard {
@@ -10,11 +13,11 @@ class RecipeCard {
this.recipe = recipe;
this.clickHandler = clickHandler;
this.element = this.createCardElement();
// Store reference to this instance on the DOM element for updates
this.element._recipeCardInstance = this;
}
createCardElement() {
const card = document.createElement('div');
card.className = 'model-card';
@@ -23,33 +26,48 @@ class RecipeCard {
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
// Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation;
// Ensure loras array exists
const loras = this.recipe.loras || [];
const lorasCount = loras.length;
// Check if all LoRAs are available in the library
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Ensure file_url exists, fallback to file_path if needed
const imageUrl = this.recipe.file_url ||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
// Check if in duplicates mode
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// Ensure file_url exists, fallback to file_path if needed
const previewUrl = this.recipe.file_url ||
(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 isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
'muted',
'loop',
'playsinline',
'preload="none"',
`data-src="${previewUrl}"`
];
if (!autoplayOnHover) {
videoAttrs.push('data-autoplay="true"');
}
// 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;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
@@ -66,15 +84,19 @@ class RecipeCard {
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
<img src="${imageUrl}" alt="${this.recipe.title}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
}
${!isDuplicatesMode ? `
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i>
@@ -102,30 +124,98 @@ class RecipeCard {
</div>
</div>
`;
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
return card;
}
getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
}
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) {
const toggleBtn = card.querySelector('.toggle-blur-btn');
const showBtn = card.querySelector('.show-content-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleBlurContent(card);
});
}
if (showBtn) {
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -137,21 +227,31 @@ class RecipeCard {
// Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) {
card.addEventListener('click', () => {
if (state.bulkMode) {
bulkManager.toggleCardSelection(card);
return;
}
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();
this.shareRecipe();
});
// Send button click event - prevent propagation to card
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
e.stopPropagation();
this.sendRecipeToWorkflow(e.shiftKey);
});
// Delete button click event - prevent propagation to card
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
e.stopPropagation();
@@ -159,19 +259,19 @@ class RecipeCard {
});
}
}
toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
@@ -182,13 +282,13 @@ class RecipeCard {
showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
@@ -223,7 +323,7 @@ class RecipeCard {
showToast('toast.recipes.sendError', {}, 'error');
}
}
showDeleteConfirmation() {
try {
// Get recipe ID
@@ -233,15 +333,21 @@ class RecipeCard {
showToast('toast.recipes.cannotDelete', {}, 'error');
return;
}
// Create delete modal content
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const deleteModalContent = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
}
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
@@ -255,7 +361,7 @@ class RecipeCard {
</div>
</div>
`;
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
@@ -264,20 +370,20 @@ class RecipeCard {
deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false;
});
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
@@ -287,19 +393,19 @@ class RecipeCard {
confirmDeleteRecipe() {
const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true;
// Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
@@ -307,27 +413,27 @@ class RecipeCard {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
}
shareRecipe() {
@@ -338,10 +444,10 @@ class RecipeCard {
showToast('toast.recipes.cannotShare', {}, 'error');
return;
}
// Show loading toast
showToast('toast.recipes.preparingForSharing', {}, 'info');
// Call the API to process the image with metadata
fetch(`/api/lm/recipe/${recipeId}/share`)
.then(response => {
@@ -354,17 +460,17 @@ class RecipeCard {
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
// Create a temporary anchor element for download
const downloadLink = document.createElement('a');
downloadLink.href = data.download_url;
downloadLink.download = data.filename;
// Append to body, click and remove
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
showToast('toast.recipes.downloadStarted', {}, 'success');
})
.catch(error => {

View File

@@ -77,11 +77,13 @@ export class SidebarManager {
this.pageControls = pageControls;
this.pageType = pageControls.pageType;
this.lastPageControls = pageControls;
this.apiClient = getModelApiClient();
this.apiClient = pageControls?.getSidebarApiClient?.()
|| pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
@@ -92,13 +94,13 @@ export class SidebarManager {
return;
}
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
this.isInitialized = true;
console.log(`SidebarManager initialized for ${this.pageType} page`);
}
@@ -111,7 +113,7 @@ export class SidebarManager {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
// Clean up event handlers
this.removeEventHandlers();
@@ -141,13 +143,13 @@ export class SidebarManager {
this.apiClient = null;
this.isInitialized = false;
this.recursiveSearchEnabled = true;
// Reset container margin
const container = document.querySelector('.container');
if (container) {
container.style.marginLeft = '';
}
// Remove resize event listener
window.removeEventListener('resize', this.updateContainerMargin);
@@ -189,10 +191,10 @@ export class SidebarManager {
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Remove document click handler
document.removeEventListener('click', this.handleDocumentClick);
// Remove resize event handler
window.removeEventListener('resize', this.updateContainerMargin);
@@ -205,6 +207,10 @@ export class SidebarManager {
}
initializeDragAndDrop() {
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
return;
}
if (!this.dragHandlersInitialized) {
document.addEventListener('dragstart', this.handleCardDragStart);
document.addEventListener('dragend', this.handleCardDragEnd);
@@ -416,7 +422,14 @@ export class SidebarManager {
}
if (!this.apiClient) {
this.apiClient = getModelApiClient();
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
}
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
}
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
@@ -470,21 +483,23 @@ export class SidebarManager {
}
async init() {
this.apiClient = getModelApiClient();
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
}
@@ -496,11 +511,11 @@ export class SidebarManager {
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
// Get stored pin state
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
this.isPinned = isPinned;
// Sidebar starts hidden by default (CSS handles this)
// Just set up the hover area state
if (window.innerWidth <= 1024) {
@@ -568,12 +583,12 @@ export class SidebarManager {
// Hover detection for auto-hide
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) {
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
@@ -583,7 +598,7 @@ export class SidebarManager {
document.addEventListener('click', (e) => {
if (window.innerWidth <= 1024 && this.isVisible) {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !sidebar.contains(e.target)) {
this.hideSidebar();
}
@@ -598,7 +613,7 @@ export class SidebarManager {
// Add document click handler for closing dropdowns
document.addEventListener('click', this.handleDocumentClick);
// Add dedicated resize listener for container margin updates
window.addEventListener('resize', this.updateContainerMargin);
@@ -645,7 +660,7 @@ export class SidebarManager {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
if (!this.isPinned) {
this.showSidebar();
}
@@ -695,9 +710,9 @@ export class SidebarManager {
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
if (window.innerWidth <= 1024) {
// Mobile: always use collapsed state
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
@@ -715,7 +730,7 @@ export class SidebarManager {
sidebar.classList.remove('collapsed', 'visible');
sidebar.classList.add('auto-hide');
hoverArea.classList.remove('disabled');
if (this.isHovering) {
sidebar.classList.add('hover-active');
this.isVisible = true;
@@ -724,7 +739,7 @@ export class SidebarManager {
this.isVisible = false;
}
}
// Update container margin when sidebar state changes
this.updateContainerMargin();
}
@@ -735,16 +750,16 @@ export class SidebarManager {
const sidebar = document.getElementById('folderSidebar');
if (!container || !sidebar || this.isDisabledBySetting) return;
// Reset margin to default
container.style.marginLeft = '';
// Only adjust margin if sidebar is visible and pinned
if ((this.isPinned || this.isHovering) && this.isVisible) {
const sidebarWidth = sidebar.offsetWidth;
const viewportWidth = window.innerWidth;
const containerWidth = container.offsetWidth;
// Check if there's enough space for both sidebar and container
// We need: sidebar width + container width + some padding < viewport width
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
@@ -822,8 +837,8 @@ export class SidebarManager {
const pinBtn = document.getElementById('sidebarPinToggle');
if (pinBtn) {
pinBtn.classList.toggle('active', this.isPinned);
pinBtn.title = this.isPinned
? translate('sidebar.unpinSidebar')
pinBtn.title = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
}
}
@@ -868,13 +883,13 @@ export class SidebarManager {
renderTreeNode(nodeData, basePath) {
const entries = Object.entries(nodeData);
if (entries.length === 0) return '';
return entries.map(([folderName, children]) => {
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
const hasChildren = Object.keys(children).length > 0;
const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath;
return `
<div class="sidebar-tree-node" data-path="${currentPath}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
@@ -919,7 +934,7 @@ export class SidebarManager {
const foldersHtml = this.foldersList.map(folder => {
const displayName = folder === '' ? '/' : folder;
const isSelected = this.selectedPath === folder;
return `
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
<div class="sidebar-node-content" data-path="${folder}">
@@ -941,13 +956,13 @@ export class SidebarManager {
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
const nodeContent = event.target.closest('.sidebar-tree-node-content');
if (expandIcon) {
// Toggle expand/collapse
const treeNode = expandIcon.closest('.sidebar-tree-node');
const path = treeNode.dataset.path;
const children = treeNode.querySelector('.sidebar-tree-children');
if (this.expandedNodes.has(path)) {
this.expandedNodes.delete(path);
expandIcon.classList.remove('expanded');
@@ -957,7 +972,7 @@ export class SidebarManager {
expandIcon.classList.add('expanded');
if (children) children.classList.add('expanded');
}
this.saveExpandedState();
} else if (nodeContent) {
// Select folder
@@ -970,7 +985,7 @@ export class SidebarManager {
handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
if (dropdownItem) {
// Handle dropdown item selection
const path = dropdownItem.dataset.path || '';
@@ -982,17 +997,17 @@ export class SidebarManager {
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
const isActive = breadcrumbItem.classList.contains('active');
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
if (isPlaceholder || (isActive && path === this.selectedPath)) {
// Open dropdown for placeholders or active items
// Close any open dropdown first
if (this.openDropdown && this.openDropdown !== dropdown) {
this.openDropdown.classList.remove('open');
}
// Toggle current dropdown
dropdown.classList.toggle('open');
// Update open dropdown reference
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
} else {
@@ -1010,21 +1025,24 @@ export class SidebarManager {
}
async selectFolder(path) {
// Normalize path: null or undefined means root
const normalizedPath = (path === null || path === undefined) ? '' : path;
// Update selected path
this.selectedPath = path;
this.selectedPath = normalizedPath;
// Update UI
this.updateTreeSelection();
this.updateBreadcrumbs();
this.updateSidebarHeader();
// Update page state
this.pageControls.pageState.activeFolder = path;
setStorageItem(`${this.pageType}_activeFolder`, path);
this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter
await this.pageControls.resetAndReload();
// Auto-hide sidebar on mobile after selection
if (window.innerWidth <= 1024) {
this.hideSidebar();
@@ -1033,7 +1051,7 @@ export class SidebarManager {
handleFolderListClick(event) {
const folderItem = event.target.closest('.sidebar-folder-item');
if (folderItem) {
const path = folderItem.dataset.path;
this.selectFolder(path);
@@ -1135,15 +1153,15 @@ export class SidebarManager {
updateTreeSelection() {
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return;
if (this.displayMode === 'list') {
// Remove all selections in list mode
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
item.classList.remove('selected');
});
// Add selection to current path
if (this.selectedPath !== null) {
if (this.selectedPath !== null && this.selectedPath !== undefined) {
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
@@ -1153,8 +1171,8 @@ export class SidebarManager {
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
node.classList.remove('selected');
});
if (this.selectedPath) {
if (this.selectedPath !== null && this.selectedPath !== undefined) {
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
if (selectedNode) {
selectedNode.classList.add('selected');
@@ -1166,15 +1184,15 @@ export class SidebarManager {
expandPathParents(path) {
if (!path) return;
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
this.expandedNodes.add(currentPath);
}
this.renderTree();
}
@@ -1184,7 +1202,7 @@ export class SidebarManager {
// Root level siblings are top-level folders
return Object.keys(this.treeData);
}
// Navigate to the parent folder to get siblings
let currentNode = this.treeData;
for (let i = 0; i < level; i++) {
@@ -1193,7 +1211,7 @@ export class SidebarManager {
}
currentNode = currentNode[pathParts[i]];
}
return Object.keys(currentNode);
}
@@ -1202,37 +1220,38 @@ export class SidebarManager {
if (!path) {
return Object.keys(this.treeData);
}
const parts = path.split('/');
let currentNode = this.treeData;
for (const part of parts) {
if (!currentNode[part]) {
return [];
}
currentNode = currentNode[part];
}
return Object.keys(currentNode);
}
updateBreadcrumbs() {
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
if (!sidebarBreadcrumbNav) return;
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
let currentPath = '';
// Start with root breadcrumb
const rootSiblings = Object.keys(this.treeData);
const isRootSelected = !this.selectedPath;
const breadcrumbs = [`
<div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path="">
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
</span>
</div>
`];
// Add separator and placeholder for next level if we're at root
if (!this.selectedPath) {
const nextLevelFolders = rootSiblings;
@@ -1251,21 +1270,21 @@ export class SidebarManager {
<div class="breadcrumb-dropdown-item" data-path="${folder}">
${folder}
</div>`).join('')
}
}
</div>
</div>
`);
}
}
// Add breadcrumb items for each path segment
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLast = index === parts.length - 1;
// Get siblings for this level
const siblings = this.getSiblingFolders(parts, index);
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
breadcrumbs.push(`
<div class="breadcrumb-dropdown">
@@ -1284,12 +1303,12 @@ export class SidebarManager {
data-path="${currentPath.replace(part, folder)}">
${folder}
</div>`).join('')
}
}
</div>
` : ''}
</div>
`);
// Add separator and placeholder for next level if not the last item
if (isLast) {
const childFolders = this.getChildFolders(currentPath);
@@ -1308,22 +1327,22 @@ export class SidebarManager {
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
${folder}
</div>`).join('')
}
}
</div>
</div>
`);
}
}
});
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
}
updateSidebarHeader() {
const sidebarHeader = document.getElementById('sidebarHeader');
if (!sidebarHeader) return;
if (this.selectedPath == null) {
if (!this.selectedPath) {
sidebarHeader.classList.add('root-selected');
} else {
sidebarHeader.classList.remove('root-selected');
@@ -1333,11 +1352,11 @@ export class SidebarManager {
toggleSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = !this.isVisible;
if (this.isVisible) {
sidebar.classList.remove('collapsed');
sidebar.classList.add('visible');
@@ -1345,28 +1364,28 @@ export class SidebarManager {
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.classList.toggle('active', this.isVisible);
}
this.saveSidebarState();
}
closeSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = false;
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
if (toggleBtn) {
toggleBtn.classList.remove('active');
}
this.saveSidebarState();
}
@@ -1375,12 +1394,12 @@ export class SidebarManager {
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
this.isPinned = isPinned;
this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode;
this.recursiveSearchEnabled = recursiveSearchEnabled;
this.updatePinButton();
this.updateDisplayModeButton();
this.updateCollapseAllButton();

View File

@@ -14,11 +14,11 @@ import { eventManager } from '../../utils/EventManager.js';
// Helper function to get display name based on settings
function getDisplayName(model) {
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
if (displayNameSetting === 'file_name') {
return model.file_name || model.model_name || 'Unknown Model';
}
return model.model_name || model.file_name || 'Unknown Model';
}
@@ -26,7 +26,7 @@ function getDisplayName(model) {
export function setupModelCardEventDelegation(modelType) {
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
@@ -42,26 +42,26 @@ function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return false; // Continue with other handlers
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return true; // Stop propagation
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return true; // Stop propagation
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return true; // Stop propagation
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
@@ -69,37 +69,37 @@ function handleModelCardEvent_internal(event, modelType) {
}
return true; // Stop propagation
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return true; // Stop propagation
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return true; // Stop propagation
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return true; // Stop propagation
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return true; // Stop propagation
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return true; // Stop propagation
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
@@ -110,14 +110,14 @@ function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
@@ -128,13 +128,13 @@ function toggleBlurContent(card) {
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
@@ -146,10 +146,10 @@ async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
@@ -239,11 +239,11 @@ function handleReplacePreview(filePath, modelType) {
async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256;
try {
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
if (data.has_images) {
openExampleImagesFolder(modelHash);
} else {
@@ -257,7 +257,7 @@ async function handleExampleImagesAccess(card, modelType) {
function handleCardClick(card, modelType) {
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
@@ -294,7 +294,7 @@ async function showModelModalFromCard(card, modelType) {
usage_tips: card.dataset.usage_tips,
})
};
await showModelModal(modelMeta, modelType);
}
@@ -310,9 +310,9 @@ function showExampleAccessModal(card, modelType) {
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images &&
Array.isArray(metaData.images) &&
metaData.images.length > 0 &&
metaData.images[0].url;
Array.isArray(metaData.images) &&
metaData.images.length > 0 &&
metaData.images[0].url;
} catch (e) {
console.error('Error parsing meta data:', e);
}
@@ -329,10 +329,10 @@ function showExampleAccessModal(card, modelType) {
showToast('modelCard.exampleImages.missingHash', {}, 'error');
return;
}
// Close the modal
modalManager.closeModal('exampleAccessModal');
try {
// Use the appropriate model API client to download examples
const apiClient = getModelApiClient(modelType);
@@ -462,7 +462,7 @@ export function createModelCard(model, modelType) {
if (model.civitai) {
card.dataset.meta = JSON.stringify(model.civitai || {});
}
// Store tags if available
if (model.tags && Array.isArray(model.tags)) {
card.dataset.tags = JSON.stringify(model.tags);
@@ -475,7 +475,7 @@ export function createModelCard(model, modelType) {
// Store NSFW level if available
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
@@ -506,7 +506,7 @@ export function createModelCard(model, modelType) {
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4');
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
'muted',
@@ -527,10 +527,10 @@ export function createModelCard(model, modelType) {
}
// Generate action icons based on model type with i18n support
const favoriteTitle = isFavorite ?
const favoriteTitle = isFavorite ?
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ?
const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
let sendTitle;
@@ -582,13 +582,13 @@ export function createModelCard(model, modelType) {
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
}
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
${shouldBlur ?
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
<i class="fas fa-eye"></i>
</button>` : ''}
<div class="card-header-info">
@@ -629,7 +629,7 @@ export function createModelCard(model, modelType) {
</div>
</div>
`;
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement) {
@@ -765,7 +765,7 @@ function cleanupHoverHandlers(videoElement) {
function requestSafePlay(videoElement) {
const playPromise = videoElement.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {});
playPromise.catch(() => { });
}
}
@@ -887,16 +887,16 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
document.body.classList.toggle('bulk-mode', isBulkMode);
// Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.model-card');
loraCards.forEach(card => {
// Get all action containers for this card
const actions = card.querySelectorAll('.card-actions');
// Handle display property based on mode
if (isBulkMode) {
// Hide actions when entering bulk mode
@@ -911,12 +911,12 @@ export function updateCardsForBulkMode(isBulkMode) {
});
}
});
// If using virtual scroller, we need to rerender after toggling bulk mode
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender();
}
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();

View File

@@ -1,15 +1,15 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
import {
toggleShowcase,
setupShowcaseScroll,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
@@ -242,7 +242,7 @@ export async function showModelModal(model, modelType) {
const modalTitle = model.model_name;
cleanupNavigationShortcuts();
detachModalHandlers(modalId);
// Fetch complete civitai metadata
let completeCivitaiData = model.civitai || {};
if (model.file_path) {
@@ -254,7 +254,7 @@ export async function showModelModal(model, modelType) {
// Continue with existing data if fetch fails
}
}
// Update model with complete civitai data
const modelWithFullData = {
...model,
@@ -269,14 +269,14 @@ export async function showModelModal(model, modelType) {
</div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
}
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>`.trim() : '';
const creatorActionItems = [];
@@ -310,10 +310,10 @@ export async function showModelModal(model, modelType) {
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
const updateAvailabilityState = { hasUpdateAvailable };
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
// Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords : [];
// Generate model type specific content
let typeSpecificContent;
@@ -343,7 +343,7 @@ export async function showModelModal(model, modelType) {
${versionsTabBadge}
</button>`.trim();
const tabsContent = modelType === 'loras' ?
const tabsContent = modelType === 'loras' ?
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton}
@@ -351,12 +351,12 @@ export async function showModelModal(model, modelType) {
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton}`;
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
const civitaiVersionId = modelWithFullData.civitai?.id || '';
@@ -373,7 +373,7 @@ export async function showModelModal(model, modelType) {
</button>
</div>`.trim();
const tabPanesContent = modelType === 'loras' ?
const tabPanesContent = modelType === 'loras' ?
`<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
@@ -518,7 +518,7 @@ export async function showModelModal(model, modelType) {
</div>
</div>
`;
function updateVersionsTabBadge(hasUpdate) {
const modalElement = document.getElementById(modalId);
if (!modalElement) return;
@@ -594,10 +594,10 @@ export async function showModelModal(model, modelType) {
updateVersionsTabBadge(hasUpdate);
updateCardUpdateAvailability(hasUpdate);
}
let showcaseCleanup;
const onCloseCallback = function() {
const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) {
@@ -610,7 +610,7 @@ export async function showModelModal(model, modelType) {
}
cleanupNavigationShortcuts();
};
modalManager.showModal(modalId, content, null, onCloseCallback);
const activeModalElement = document.getElementById(modalId);
if (activeModalElement) {
@@ -643,17 +643,17 @@ export async function showModelModal(model, modelType) {
setupEventHandlers(modelWithFullData.file_path, modelType);
setupNavigationShortcuts(modelType);
updateNavigationControls();
// LoRA specific setup
if (modelType === 'loras' || modelType === 'embeddings') {
setupTriggerWordsEditMode();
if (modelType == 'loras') {
// Load recipes for this LoRA
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
}
}
// Load example images asynchronously - merge regular and custom images
const regularImages = modelWithFullData.civitai?.images || [];
const customImages = modelWithFullData.civitai?.customImages || [];
@@ -707,17 +707,17 @@ function detachModalHandlers(modalId) {
*/
function setupEventHandlers(filePath, modelType) {
const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'close-modal':
modalManager.closeModal('modelModal');
@@ -748,10 +748,10 @@ function setupEventHandlers(filePath, modelType) {
break;
}
}
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick;
}
@@ -763,15 +763,15 @@ function setupEventHandlers(filePath, modelType) {
*/
function setupEditableFields(filePath, modelType) {
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
editableFields.forEach(field => {
field.addEventListener('focus', function() {
field.addEventListener('focus', function () {
if (this.textContent === 'Add your notes here...') {
this.textContent = '';
}
});
field.addEventListener('blur', function() {
field.addEventListener('blur', function () {
if (this.textContent.trim() === '') {
if (this.classList.contains('notes-content')) {
this.textContent = 'Add your notes here...';
@@ -783,7 +783,7 @@ function setupEditableFields(filePath, modelType) {
// Add keydown event listeners for notes
const notesContent = document.querySelector('.notes-content');
if (notesContent) {
notesContent.addEventListener('keydown', async function(e) {
notesContent.addEventListener('keydown', async function (e) {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Allow shift+enter for new line
@@ -810,7 +810,7 @@ function setupLoraSpecificFields(filePath) {
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
presetSelector.addEventListener('change', function() {
presetSelector.addEventListener('change', function () {
const selected = this.value;
if (selected) {
presetValue.style.display = 'inline-block';
@@ -828,10 +828,10 @@ function setupLoraSpecificFields(filePath) {
}
});
addPresetBtn.addEventListener('click', async function() {
addPresetBtn.addEventListener('click', async function () {
const key = presetSelector.value;
const value = presetValue.value;
if (!key || !value) return;
const currentPath = resolveFilePath();
@@ -839,21 +839,21 @@ function setupLoraSpecificFields(filePath) {
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
currentPresets[key] = parseFloat(value);
const newPresetsJson = JSON.stringify(currentPresets);
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
});
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
presetValue.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute } from './utils.js';
import { escapeAttribute, escapeHtml } from './utils.js';
/**
* Fetch trained words for a model
@@ -17,7 +17,7 @@ async function fetchTrainedWords(filePath) {
try {
const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.success) {
return {
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
@@ -43,11 +43,11 @@ async function fetchTrainedWords(filePath) {
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
@@ -55,12 +55,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
return dropdown;
}
// Sort trained words by frequency (highest first) if available
if (trainedWords && trainedWords.length > 0) {
trainedWords.sort((a, b) => b[1] - a[1]);
}
// Add class tokens section if available
if (classTokens) {
// Add class tokens header
@@ -71,45 +71,47 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
`;
dropdown.appendChild(classTokensHeader);
// Add class tokens container
const classTokensContainer = document.createElement('div');
classTokensContainer.className = 'class-tokens-container';
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
const escapedToken = escapeHtml(classTokens);
tokenItem.innerHTML = `
<span class="metadata-suggestion-text">${classTokens}</span>
<span class="metadata-suggestion-text">${escapedToken}</span>
<div class="metadata-suggestion-meta">
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
${existingWords.includes(classTokens) ?
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
${existingWords.includes(classTokens) ?
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
// Add click handler if not already added
if (!existingWords.includes(classTokens)) {
tokenItem.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(classTokens);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = classTokens;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
classTokensContainer.appendChild(tokenItem);
dropdown.appendChild(classTokensContainer);
// Add separator if we also have trained words
if (trainedWords && trainedWords.length > 0) {
const separator = document.createElement('div');
@@ -117,7 +119,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.appendChild(separator);
}
}
// Add trained words header if we have any
if (trainedWords && trainedWords.length > 0) {
header.innerHTML = `
@@ -125,52 +127,54 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
`;
dropdown.appendChild(header);
// Create tag container for trained words
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each trained word as a tag
trainedWords.forEach(([word, frequency]) => {
const isAdded = existingWords.includes(word);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated
const escapedWord = escapeHtml(word);
item.innerHTML = `
<span class="metadata-suggestion-text">${word}</span>
<span class="metadata-suggestion-text">${escapedWord}</span>
<div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
if (!isAdded) {
item.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(word);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
}
return dropdown;
}
@@ -204,7 +208,7 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
`;
return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
@@ -215,9 +219,12 @@ export function renderTriggerWords(words, filePath) {
</div>
<div class="trigger-words-content">
<div class="trigger-words-tags">
${words.map(word => `
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${word}</span>
${words.map(word => {
const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
@@ -225,7 +232,7 @@ export function renderTriggerWords(words, filePath) {
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
`}).join('')}
</div>
</div>
<div class="metadata-edit-controls" style="display:none;">
@@ -250,68 +257,68 @@ export function setupTriggerWordsEditMode() {
let isTrainedWordsLoaded = false;
// Store original trigger words for restoring on cancel
let originalTriggerWords = [];
const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return;
editBtn.addEventListener('click', async function() {
editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = translate('modals.model.triggerWords.cancel');
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
// Show edit controls and input form
editControls.style.display = 'flex';
addForm.style.display = 'flex';
// If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container
if (noTriggerWords) {
noTriggerWords.style.display = 'none';
if (tagsContainer) tagsContainer.style.display = 'flex';
}
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
deleteBtn.style.display = 'block';
// Re-attach event listener to ensure it works every time
// First remove any existing listeners to avoid duplication
deleteBtn.removeEventListener('click', deleteTriggerWord);
deleteBtn.addEventListener('click', deleteTriggerWord);
}
});
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
addForm.appendChild(loadingIndicator);
// Get currently added trigger words
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Asynchronously load trained words if not already loaded
if (!isTrainedWordsLoaded) {
const result = await fetchTrainedWords(filePath);
@@ -319,25 +326,25 @@ export function setupTriggerWordsEditMode() {
classTokensValue = result.classTokens;
isTrainedWordsLoaded = true;
}
// Remove loading indicator
loadingIndicator.remove();
// Create and display suggestion dropdown
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
addForm.appendChild(dropdown);
// Focus the input
addForm.querySelector('input').focus();
} else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit');
// Hide edit controls and input form
editControls.style.display = 'none';
addForm.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original trigger words
@@ -348,7 +355,7 @@ export function setupTriggerWordsEditMode() {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
// If we have no trigger words, show the "No trigger word needed" text
// and hide the empty tags container
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
@@ -356,19 +363,19 @@ export function setupTriggerWordsEditMode() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}
});
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.metadata-input');
if (triggerWordInput) {
// Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) {
triggerWordInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTriggerWord(this.value);
@@ -376,13 +383,13 @@ export function setupTriggerWordsEditMode() {
}
});
}
// Set up save button
const saveBtn = document.querySelector('.metadata-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', saveTriggerWords);
}
// Set up delete buttons
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
// Remove any existing listeners to avoid duplication
@@ -399,7 +406,7 @@ function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -410,15 +417,15 @@ function deleteTriggerWord(e) {
*/
function resetTriggerWordsUIState(section) {
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word);
tag.onclick = () => copyTriggerWord(tag.dataset.word);
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
if (deleteBtn) deleteBtn.style.display = 'none';
@@ -433,30 +440,32 @@ function resetTriggerWordsUIState(section) {
function restoreOriginalTriggerWords(section, originalWords) {
const tagsContainer = section.querySelector('.trigger-words-tags');
const noTriggerWords = section.querySelector('.no-trigger-words');
if (!tagsContainer) return;
// Clear current tags
tagsContainer.innerHTML = '';
if (originalWords.length === 0) {
if (noTriggerWords) noTriggerWords.style.display = '';
tagsContainer.style.display = 'none';
return;
}
// Hide "no trigger words" message
if (noTriggerWords) noTriggerWords.style.display = 'none';
tagsContainer.style.display = 'flex';
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(word);
tag.onclick = () => copyTriggerWord(tag.dataset.word);
const escapedWord = escapeHtml(word);
tag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
@@ -475,10 +484,10 @@ function restoreOriginalTriggerWords(section, originalWords) {
function addNewTriggerWord(word) {
word = word.trim();
if (!word) return;
const triggerWordsSection = document.querySelector('.trigger-words');
let tagsContainer = document.querySelector('.trigger-words-tags');
// Ensure tags container exists and is visible
if (tagsContainer) {
tagsContainer.style.display = 'flex';
@@ -491,41 +500,43 @@ function addNewTriggerWord(word) {
contentDiv.appendChild(tagsContainer);
}
}
if (!tagsContainer) return;
// Hide "no trigger words" message if it exists
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
if (noTriggerWordsMsg) {
noTriggerWordsMsg.style.display = 'none';
}
// Validation: Check length
if (word.split(/\s+/).length > 100) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 30) {
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
const escapedWord = escapeHtml(word);
newTag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
@@ -533,13 +544,13 @@ function addNewTriggerWord(word) {
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -550,19 +561,19 @@ function addNewTriggerWord(word) {
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current trigger words
const currentTags = document.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingWords.includes(wordText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
@@ -572,27 +583,27 @@ function updateTrainedWordsDropdown() {
indicator.innerHTML = '<i class="fas fa-check"></i>';
meta.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.metadata-suggestion-text').textContent;
addNewTriggerWord(word);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus the input
if (input) input.focus();
};
@@ -610,19 +621,19 @@ async function saveTriggerWords() {
const triggerWordsSection = editBtn.closest('.trigger-words');
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
try {
// Special format for updating nested civitai.trainedWords
await getModelApiClient().saveModelMetadata(filePath, {
civitai: { trainedWords: words }
});
// Set flag to skip restoring original words when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Exit edit mode without restoring original trigger words
editBtn.click();
// If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
@@ -630,7 +641,7 @@ async function saveTriggerWords() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
showToast('toast.triggerWords.updateSuccess', {}, 'success');
} catch (error) {
console.error('Error saving trigger words:', error);
@@ -642,7 +653,7 @@ async function saveTriggerWords() {
* Copy a trigger word to clipboard
* @param {string} word - Word to copy
*/
window.copyTriggerWord = async function(word) {
window.copyTriggerWord = async function (word) {
try {
await copyToClipboard(word, 'Trigger word copied');
} catch (err) {

View File

@@ -60,14 +60,12 @@ export class AppCore {
initTheme();
initBackToTop();
// Initialize the bulk manager and context menu only if not on recipes page
if (state.currentPageType !== 'recipes') {
bulkManager.initialize();
// Initialize the bulk manager and context menu
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
}
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
// Initialize the example images manager
exampleImagesManager.initialize();
@@ -84,10 +82,7 @@ export class AppCore {
// Start onboarding if needed (after everything is initialized)
setTimeout(() => {
// Do not show onboarding if version-mismatch banner is visible
if (!bannerService.isBannerVisible('version-mismatch')) {
onboardingManager.start();
}
onboardingManager.start();
}, 1000); // Small delay to ensure all elements are rendered
// Return the core instance for chaining
@@ -124,4 +119,4 @@ export class AppCore {
}
// Create and export a singleton instance
export const appCore = new AppCore();
export const appCore = new AppCore();

View File

@@ -17,7 +17,7 @@ const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai';
const BANNER_HISTORY_KEY = 'banner_history';
const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at';
const BANNER_HISTORY_LIMIT = 20;
const HISTORY_EXCLUDED_IDS = new Set(['version-mismatch']);
const HISTORY_EXCLUDED_IDS = new Set([]);
/**
* Banner Service for managing notification banners

View File

@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -62,9 +63,22 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true
},
recipes: {
addTags: false,
sendToWorkflow: false,
copyAll: false,
refreshAll: false,
checkUpdates: false,
moveAll: true,
autoOrganize: false,
deleteAll: true,
setContentRating: false
}
};
this.recipeApiClient = null;
window.addEventListener('lm:priority-tags-updated', () => {
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
if (!container) {
@@ -87,9 +101,6 @@ export class BulkManager {
}
initialize() {
// Do not initialize on recipes page
if (state.currentPageType === 'recipes') return;
// Register with event manager for coordinated event handling
this.registerEventHandlers();
@@ -97,6 +108,23 @@ export class BulkManager {
eventManager.setState('bulkMode', state.bulkMode || false);
}
getActiveApiClient() {
if (state.currentPageType === 'recipes') {
if (!this.recipeApiClient) {
this.recipeApiClient = new RecipeSidebarApiClient();
}
return this.recipeApiClient;
}
return getModelApiClient();
}
getCurrentDisplayConfig() {
if (state.currentPageType === 'recipes') {
return { displayName: 'Recipe' };
}
return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' };
}
setBulkContextMenu(bulkContextMenu) {
this.bulkContextMenu = bulkContextMenu;
}
@@ -240,7 +268,9 @@ export class BulkManager {
// Update event manager state
eventManager.setState('bulkMode', state.bulkMode);
this.bulkBtn.classList.toggle('active', state.bulkMode);
if (this.bulkBtn) {
this.bulkBtn.classList.toggle('active', state.bulkMode);
}
updateCardsForBulkMode(state.bulkMode);
@@ -504,13 +534,13 @@ export class BulkManager {
modalManager.closeModal('bulkDeleteModal');
try {
const apiClient = getModelApiClient();
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) {
const currentConfig = MODEL_CONFIG[state.currentPageType];
const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count,
type: currentConfig.displayName.toLowerCase()
@@ -570,7 +600,7 @@ export class BulkManager {
this.applySelectionState();
const newlySelected = state.selectedModels.size - oldCount;
const currentConfig = MODEL_CONFIG[state.currentPageType];
const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.selectedAdditional', {
count: newlySelected,
type: currentConfig.displayName.toLowerCase()
@@ -622,8 +652,7 @@ export class BulkManager {
return;
}
const currentType = state.currentPageType;
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
const currentConfig = this.getCurrentDisplayConfig();
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
@@ -969,7 +998,7 @@ export class BulkManager {
modalManager.closeModal('bulkAddTagsModal');
if (successCount > 0) {
const currentConfig = MODEL_CONFIG[state.currentPageType];
const currentConfig = this.getCurrentDisplayConfig();
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
showToast(toastKey, {
count: successCount,

View File

@@ -3,32 +3,33 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
export class FilterManager {
constructor(options = {}) {
this.options = {
...options
};
this.currentPage = options.page || document.body.dataset.page || 'loras';
const pageState = getCurrentPageState();
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false;
this.initialize();
// Store this instance in the state
if (pageState) {
pageState.filterManager = this;
pageState.filters = this.cloneFilters();
}
}
initialize() {
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
@@ -50,39 +51,39 @@ export class FilterManager {
this.toggleFilterPanel();
});
}
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
this.closeFilterPanel();
}
});
// Initialize active filters from localStorage if available
this.loadFiltersFromStorage();
}
async loadTopTags() {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (!tagsContainer) return;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags');
const data = await response.json();
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
// After creating tag elements, mark any previously selected ones
this.updateTagSelections();
} else {
@@ -96,57 +97,79 @@ export class FilterManager {
}
}
}
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
// Add click handler to cycle through tri-state filter and automatically apply
tagEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(tagName, newState);
this.applyTagElementState(tagEl, newState);
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
tagsContainer.appendChild(tagEl);
});
// Add "No tags" as a special filter at the end
const noTagsEl = document.createElement('div');
noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
const noTagsKey = '__no_tags__';
noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState);
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
tagsContainer.appendChild(noTagsEl);
}
initializeLicenseFilters() {
const licenseTags = document.querySelectorAll('.license-tag');
licenseTags.forEach(tag => {
tag.addEventListener('click', async () => {
const licenseType = tag.dataset.license;
// Ensure license object exists
if (!this.filters.license) {
this.filters.license = {};
}
// Get current state
let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude
// Cycle through states: none -> include -> exclude -> none
let newState;
switch (currentState) {
@@ -165,7 +188,7 @@ export class FilterManager {
tag.classList.remove('active', 'exclude');
break;
}
// Update filter state
if (newState === 'none') {
delete this.filters.license[licenseType];
@@ -176,27 +199,27 @@ export class FilterManager {
} else {
this.filters.license[licenseType] = newState;
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
});
// Update selections based on stored filters
this.updateLicenseSelections();
}
updateLicenseSelections() {
const licenseTags = document.querySelectorAll('.license-tag');
licenseTags.forEach(tag => {
const licenseType = tag.dataset.license;
const state = (this.filters.license && this.filters.license[licenseType]) || 'none';
// Reset classes
tag.classList.remove('active', 'exclude');
// Apply appropriate class based on state
switch (state) {
case 'include':
@@ -211,31 +234,31 @@ export class FilterManager {
}
});
}
createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
// Fetch base models
fetch(apiEndpoint)
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
@@ -243,24 +266,24 @@ export class FilterManager {
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
}
})
.catch(error => {
console.error(`Error fetching base models for ${this.currentPage}:`, error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
})
.catch(error => {
console.error(`Error fetching base models for ${this.currentPage}:`, error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
async createModelTypeTags() {
@@ -336,18 +359,18 @@ export class FilterManager {
modelTypeContainer.innerHTML = '<div class="tags-error">Failed to load model types</div>';
}
}
toggleFilterPanel() {
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
if (isHidden) {
// Update panel positions before showing
updatePanelPositions();
this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active');
// Load tags if they haven't been loaded yet
if (!this.tagsLoaded) {
this.loadTopTags();
@@ -358,7 +381,7 @@ export class FilterManager {
}
}
}
closeFilterPanel() {
if (this.filterPanel) {
this.filterPanel.classList.add('hidden');
@@ -367,7 +390,7 @@ export class FilterManager {
this.filterButton.classList.remove('active');
}
}
updateTagSelections() {
// Update base model tags
const baseModelTags = document.querySelectorAll('.base-model-tag');
@@ -379,7 +402,7 @@ export class FilterManager {
tag.classList.remove('active');
}
});
// Update model tags
const modelTags = document.querySelectorAll('.tag-filter');
modelTags.forEach(tag => {
@@ -387,7 +410,7 @@ export class FilterManager {
const state = (this.filters.tags && this.filters.tags[tagName]) || 'none';
this.applyTagElementState(tag, state);
});
// Update license tags if visible on this page
if (this.shouldShowLicenseFilters()) {
this.updateLicenseSelections();
@@ -406,13 +429,13 @@ export class FilterManager {
}
});
}
updateActiveFiltersCount() {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeFilterCount = this.filters.modelTypes.length;
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
@@ -422,18 +445,18 @@ export class FilterManager {
}
}
}
async applyFilters(showToastNotification = true) {
const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage
const filtersSnapshot = this.cloneFilters();
setStorageItem(storageKey, filtersSnapshot);
// Update state with current filters
pageState.filters = filtersSnapshot;
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
@@ -441,14 +464,14 @@ export class FilterManager {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
}
// Update filter button to show active state
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
if (showToastNotification) {
const baseModelCount = this.filters.baseModel.length;
const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
let message = '';
if (baseModelCount > 0 && tagsCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
@@ -457,7 +480,7 @@ export class FilterManager {
} else if (tagsCount > 0) {
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
}
showToast('toast.filters.applied', { message }, 'success');
}
} else {
@@ -467,7 +490,7 @@ export class FilterManager {
}
}
}
async clearFilters() {
// Clear all filters
this.filters = this.initializeFilters({
@@ -477,52 +500,52 @@ export class FilterManager {
license: {},
modelTypes: []
});
// Update state
const pageState = getCurrentPageState();
pageState.filters = this.cloneFilters();
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Remove from local Storage
const storageKey = `${this.currentPage}_filters`;
removeStorageItem(storageKey);
// Update UI
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
} else {
this.filterButton.classList.remove('active');
}
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}
showToast('toast.filters.cleared', {}, 'info');
}
loadFiltersFromStorage() {
const storageKey = `${this.currentPage}_filters`;
const savedFilters = getStorageItem(storageKey);
if (savedFilters) {
try {
// Ensure backward compatibility with older filter format
this.filters = this.initializeFilters(savedFilters);
// Update state with loaded filters
const pageState = getCurrentPageState();
pageState.filters = this.cloneFilters();
this.updateTagSelections();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
}
@@ -531,7 +554,7 @@ export class FilterManager {
}
}
}
hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;

View File

@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { sidebarManager } from '../components/SidebarManager.js';
@@ -12,11 +13,22 @@ class MoveManager {
this.bulkFilePaths = null;
this.folderTreeManager = new FolderTreeManager();
this.initialized = false;
this.recipeApiClient = null;
// Bind methods
this.updateTargetPath = this.updateTargetPath.bind(this);
}
_getApiClient(modelType = null) {
if (state.currentPageType === 'recipes') {
if (!this.recipeApiClient) {
this.recipeApiClient = new RecipeSidebarApiClient();
}
return this.recipeApiClient;
}
return getModelApiClient(modelType);
}
initializeEventListeners() {
if (this.initialized) return;
@@ -36,7 +48,7 @@ class MoveManager {
this.currentFilePath = null;
this.bulkFilePaths = null;
const apiClient = getModelApiClient();
const apiClient = this._getApiClient(modelType);
const currentPageType = state.currentPageType;
const modelConfig = apiClient.apiConfig.config;
@@ -121,7 +133,7 @@ class MoveManager {
async initializeFolderTree() {
try {
const apiClient = getModelApiClient();
const apiClient = this._getApiClient();
// Fetch unified folder tree
const treeData = await apiClient.fetchUnifiedFolderTree();
@@ -141,7 +153,7 @@ class MoveManager {
updateTargetPath() {
const pathDisplay = document.getElementById('moveTargetPathDisplay');
const modelRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const apiClient = this._getApiClient();
const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
@@ -158,7 +170,7 @@ class MoveManager {
async moveModel() {
const selectedRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const apiClient = this._getApiClient();
const config = apiClient.apiConfig.config;
if (!selectedRoot) {

View File

@@ -4,8 +4,7 @@ import {
setStorageItem,
getStoredVersionInfo,
setStoredVersionInfo,
isVersionMatch,
resetDismissedBanner
isVersionMatch
} from '../utils/storageHelpers.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -753,94 +752,14 @@ export class UpdateService {
stored: getStoredVersionInfo()
});
// Reset dismissed status for version mismatch banner
resetDismissedBanner('version-mismatch');
// Register and show the version mismatch banner
this.registerVersionMismatchBanner();
// Silently update stored version info as cache busting handles the resource updates
setStoredVersionInfo(this.currentVersionInfo);
}
}
} catch (error) {
console.error('Failed to check version info:', error);
}
}
registerVersionMismatchBanner() {
// Get stored and current version for display
const storedVersion = getStoredVersionInfo() || translate('common.status.unknown');
const currentVersion = this.currentVersionInfo || translate('common.status.unknown');
bannerService.registerBanner('version-mismatch', {
id: 'version-mismatch',
title: translate('banners.versionMismatch.title', {}, 'Application Update Detected'),
content: translate('banners.versionMismatch.content', {
storedVersion,
currentVersion
}, `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`),
actions: [
{
text: translate('banners.versionMismatch.refreshNow', {}, 'Refresh Now'),
icon: 'fas fa-sync',
action: 'hardRefresh',
type: 'primary'
}
],
dismissible: false,
priority: 10,
countdown: 15,
onRegister: (bannerElement) => {
// Add countdown element
const countdownEl = document.createElement('div');
countdownEl.className = 'banner-countdown';
countdownEl.innerHTML = `<span>${translate('banners.versionMismatch.refreshingIn', {}, 'Refreshing in')} <strong>15</strong> ${translate('banners.versionMismatch.seconds', {}, 'seconds')}...</span>`;
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
// Start countdown
let seconds = 15;
const countdownInterval = setInterval(() => {
seconds--;
const strongEl = countdownEl.querySelector('strong');
if (strongEl) strongEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownInterval);
this.performHardRefresh();
}
}, 1000);
// Store interval ID for cleanup
bannerElement.dataset.countdownInterval = countdownInterval;
// Add action button event handler
const actionBtn = bannerElement.querySelector('.banner-action[data-action="hardRefresh"]');
if (actionBtn) {
actionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearInterval(countdownInterval);
this.performHardRefresh();
});
}
},
onRemove: (bannerElement) => {
// Clear any existing interval
const intervalId = bannerElement.dataset.countdownInterval;
if (intervalId) {
clearInterval(parseInt(intervalId));
}
}
});
}
performHardRefresh() {
// Update stored version info before refreshing
setStoredVersionInfo(this.currentVersionInfo);
// Force a hard refresh by adding cache-busting parameter
const cacheBuster = new Date().getTime();
window.location.href = window.location.pathname +
(window.location.search ? window.location.search + '&' : '?') +
`cache=${cacheBuster}`;
}
}
// Create and export singleton instance

View File

@@ -12,21 +12,21 @@ export class DownloadManager {
async saveRecipe() {
// Check if we're in download-only mode (for existing recipe)
const isDownloadOnly = !!this.importManager.recipeId;
if (!isDownloadOnly && !this.importManager.recipeName) {
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
try {
// Show progress indicator
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
// Only send the complete recipe to save if not in download-only mode
if (!isDownloadOnly) {
// Create FormData object for saving recipe
const formData = new FormData();
// Add image data - depends on import mode
if (this.importManager.recipeImage) {
// Direct upload
@@ -45,10 +45,10 @@ export class DownloadManager {
} else {
throw new Error('No image data available');
}
formData.append('name', this.importManager.recipeName);
formData.append('tags', JSON.stringify(this.importManager.recipeTags));
// Prepare complete metadata including generation parameters
const completeMetadata = {
base_model: this.importManager.recipeData.base_model || "",
@@ -65,7 +65,11 @@ export class DownloadManager {
if (checkpointMetadata && typeof checkpointMetadata === 'object') {
completeMetadata.checkpoint = checkpointMetadata;
}
if (this.importManager.recipeData && this.importManager.recipeData.extension) {
formData.append('extension', this.importManager.recipeData.extension);
}
// Add source_path to metadata to track where the recipe was imported from
if (this.importManager.importMode === 'url') {
const urlInput = document.getElementById('imageUrlInput');
@@ -73,15 +77,15 @@ export class DownloadManager {
completeMetadata.source_path = urlInput.value;
}
}
formData.append('metadata', JSON.stringify(completeMetadata));
// Send save request
const response = await fetch('/api/lm/recipes/save', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
@@ -102,19 +106,19 @@ export class DownloadManager {
// Show success message
if (isDownloadOnly) {
if (failedDownloads === 0) {
if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success');
}
} else {
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
}
// Close modal
modalManager.closeModal('importModal');
// Refresh the recipe
window.recipeManager.loadRecipes();
} catch (error) {
console.error('Error:', error);
showToast('toast.recipes.processingError', { message: error.message }, 'error');
@@ -129,49 +133,49 @@ export class DownloadManager {
if (!loraRoot) {
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
}
// Build target path
let targetPath = '';
if (this.importManager.selectedFolder) {
targetPath = this.importManager.selectedFolder;
}
// Generate a unique ID for this batch download
const batchDownloadId = Date.now().toString();
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
// Show enhanced loading with progress details for multiple items
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
this.importManager.downloadableLoRAs.length
);
let completedDownloads = 0;
let failedDownloads = 0;
let accessFailures = 0;
let currentLoraProgress = 0;
// Set up progress tracking for current download
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle download ID confirmation
if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return;
}
// Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress
currentLoraProgress = data.progress;
// Get current LoRA name
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
const loraName = currentLora ? currentLora.name : '';
// Update progress display
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -180,7 +184,7 @@ export class DownloadManager {
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Add more detailed status messages based on progress
if (currentLoraProgress < 3) {
this.importManager.loadingManager.setStatus(
@@ -203,17 +207,17 @@ export class DownloadManager {
};
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
const lora = this.importManager.downloadableLoRAs[i];
// Reset current LoRA progress for new download
currentLoraProgress = 0;
// Initial status update for new LoRA
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`));
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i + 1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i + 1}/${this.importManager.downloadableLoRAs.length}`));
updateProgress(0, completedDownloads, lora.name);
try {
// Download the LoRA with download ID
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
@@ -224,7 +228,7 @@ export class DownloadManager {
useDefaultPaths,
batchDownloadId
);
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
@@ -248,28 +252,28 @@ export class DownloadManager {
// Continue with next download
}
}
// Close WebSocket
ws.close();
// Show appropriate completion message based on results
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
showToast('toast.loras.downloadPartialWithAccess', {
completed: completedDownloads,
showToast('toast.loras.downloadPartialWithAccess', {
completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length,
accessFailures: accessFailures
}, 'error');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length
}, 'error');
}
}
return failedDownloads;
}
}

View File

@@ -8,10 +8,10 @@ export class RecipeDataManager {
showRecipeDetailsStep() {
this.importManager.stepManager.showStep('detailsStep');
// Set default recipe name from prompt or image filename
const recipeName = document.getElementById('recipeName');
// Check if we have recipe metadata from a shared recipe
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
// Use title from recipe metadata
@@ -19,24 +19,24 @@ export class RecipeDataManager {
recipeName.value = this.importManager.recipeData.title;
this.importManager.recipeName = this.importManager.recipeData.title;
}
// Use tags from recipe metadata
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
this.importManager.recipeTags = [...this.importManager.recipeData.tags];
this.updateTagsDisplay();
}
} else if (this.importManager.recipeData &&
this.importManager.recipeData.gen_params &&
this.importManager.recipeData.gen_params.prompt) {
} else if (this.importManager.recipeData &&
this.importManager.recipeData.gen_params &&
this.importManager.recipeData.gen_params.prompt) {
// Use the first 10 words from the prompt as the default recipe name
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
const truncatedPrompt = promptWords.slice(0, 10).join(' ');
recipeName.value = truncatedPrompt;
this.importManager.recipeName = truncatedPrompt;
// Set up click handler to select all text for easy editing
if (!recipeName.hasSelectAllHandler) {
recipeName.addEventListener('click', function() {
recipeName.addEventListener('click', function () {
this.select();
});
recipeName.hasSelectAllHandler = true;
@@ -47,15 +47,15 @@ export class RecipeDataManager {
recipeName.value = fileName;
this.importManager.recipeName = fileName;
}
// Always set up click handler for easy editing if not already set
if (!recipeName.hasSelectAllHandler) {
recipeName.addEventListener('click', function() {
recipeName.addEventListener('click', function () {
this.select();
});
recipeName.hasSelectAllHandler = true;
}
// Display the uploaded image in the preview
const imagePreview = document.getElementById('recipeImagePreview');
if (imagePreview) {
@@ -67,13 +67,24 @@ export class RecipeDataManager {
};
reader.readAsDataURL(this.importManager.recipeImage);
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
// For URL mode - use the base64 image data returned from the backend
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
// For URL mode - use the base64 data returned from the backend
if (this.importManager.recipeData.is_video) {
const mimeType = this.importManager.recipeData.extension === '.webm' ? 'video/webm' : 'video/mp4';
imagePreview.innerHTML = `<video src="data:${mimeType};base64,${this.importManager.recipeData.image_base64}" controls autoplay loop muted class="recipe-preview-video"></video>`;
} else {
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
}
} else if (this.importManager.importMode === 'url') {
// Fallback for URL mode if no base64 data
const urlInput = document.getElementById('imageUrlInput');
if (urlInput && urlInput.value) {
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
const url = urlInput.value.toLowerCase();
if (url.endsWith('.mp4') || url.endsWith('.webm')) {
const mimeType = url.endsWith('.webm') ? 'video/webm' : 'video/mp4';
imagePreview.innerHTML = `<video src="${urlInput.value}" controls autoplay loop muted class="recipe-preview-video"></video>`;
} else {
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
}
}
}
}
@@ -85,7 +96,7 @@ export class RecipeDataManager {
if (loraCountInfo) {
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
}
// Display LoRAs list
const lorasList = document.getElementById('lorasList');
if (lorasList) {
@@ -94,7 +105,7 @@ export class RecipeDataManager {
const isDeleted = lora.isDeleted;
const isEarlyAccess = lora.isEarlyAccess;
const localPath = lora.localPath || '';
// Create status badge based on LoRA status
let statusBadge;
if (isDeleted) {
@@ -102,7 +113,7 @@ export class RecipeDataManager {
<i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
</div>`;
} else {
statusBadge = existsLocally ?
statusBadge = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
<div class="local-path">${localPath}</div>
@@ -126,7 +137,7 @@ export class RecipeDataManager {
console.warn('Failed to format early access date', e);
}
}
earlyAccessBadge = `<div class="early-access-badge">
<i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
<div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div>
@@ -134,7 +145,7 @@ export class RecipeDataManager {
}
// Format size if available
const sizeDisplay = lora.size ?
const sizeDisplay = lora.size ?
`<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : '';
return `
@@ -161,9 +172,9 @@ export class RecipeDataManager {
`;
}).join('');
}
// Check for early access loras and show warning if any exist
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted);
if (earlyAccessLoras.length > 0) {
// Show a warning about early access loras
@@ -179,7 +190,7 @@ export class RecipeDataManager {
</div>
</div>
`;
// Show the warning message
const buttonsContainer = document.querySelector('#detailsStep .modal-actions');
if (buttonsContainer) {
@@ -188,7 +199,7 @@ export class RecipeDataManager {
if (existingWarning) {
existingWarning.remove();
}
// Add new warning
const warningContainer = document.createElement('div');
warningContainer.id = 'earlyAccessWarning';
@@ -196,27 +207,27 @@ export class RecipeDataManager {
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
}
}
// Check for duplicate recipes and display warning if found
this.checkAndDisplayDuplicates();
// Update Next button state based on missing LoRAs and duplicates
this.updateNextButtonState();
}
checkAndDisplayDuplicates() {
// Check if we have duplicate recipes
if (this.importManager.recipeData &&
this.importManager.recipeData.matching_recipes &&
if (this.importManager.recipeData &&
this.importManager.recipeData.matching_recipes &&
this.importManager.recipeData.matching_recipes.length > 0) {
// Store duplicates in the importManager for later use
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
// Create duplicate warning container
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
this.createDuplicateContainer();
// Format date helper function
const formatDate = (timestamp) => {
try {
@@ -226,7 +237,7 @@ export class RecipeDataManager {
return 'Unknown date';
}
};
// Generate the HTML for duplicate recipes warning
duplicateContainer.innerHTML = `
<div class="duplicate-warning">
@@ -262,10 +273,10 @@ export class RecipeDataManager {
`).join('')}
</div>
`;
// Show the duplicate container
duplicateContainer.style.display = 'block';
// Add click event for the toggle button
const toggleButton = document.getElementById('toggleDuplicatesList');
if (toggleButton) {
@@ -290,49 +301,49 @@ export class RecipeDataManager {
if (duplicateContainer) {
duplicateContainer.style.display = 'none';
}
// Reset duplicate tracking
this.importManager.duplicateRecipes = [];
}
}
createDuplicateContainer() {
// Find where to insert the duplicate container
const lorasListContainer = document.querySelector('.input-group:has(#lorasList)');
if (!lorasListContainer) return null;
// Create container
const duplicateContainer = document.createElement('div');
duplicateContainer.id = 'duplicateRecipesContainer';
duplicateContainer.className = 'duplicate-recipes-container';
// Insert before the LoRA list
lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer);
return duplicateContainer;
}
updateNextButtonState() {
const nextButton = document.querySelector('#detailsStep .primary-btn');
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
if (!nextButton || !actionsContainer) return;
// Always clean up previous warnings and buttons first
const existingWarning = document.getElementById('deletedLorasWarning');
if (existingWarning) {
existingWarning.remove();
}
// Remove any existing "import anyway" button
const importAnywayBtn = document.getElementById('importAnywayBtn');
if (importAnywayBtn) {
importAnywayBtn.remove();
}
// Count deleted LoRAs
const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length;
// If we have deleted LoRAs, show a warning
if (deletedLoras > 0) {
// Create a new warning container above the buttons
@@ -340,7 +351,7 @@ export class RecipeDataManager {
const warningContainer = document.createElement('div');
warningContainer.id = 'deletedLorasWarning';
warningContainer.className = 'deleted-loras-warning';
// Create warning message
warningContainer.innerHTML = `
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
@@ -349,19 +360,19 @@ export class RecipeDataManager {
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
</div>
`;
// Insert before the buttons container
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
}
// Check for duplicates but don't change button actions
const missingNotDeleted = this.importManager.recipeData.loras.filter(
lora => !lora.existsLocally && !lora.isDeleted
).length;
// Standard button behavior regardless of duplicates
nextButton.classList.remove('warning-btn');
if (missingNotDeleted > 0) {
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
} else {
@@ -372,30 +383,30 @@ export class RecipeDataManager {
addTag() {
const tagInput = document.getElementById('tagInput');
const tag = tagInput.value.trim();
if (!tag) return;
if (!this.importManager.recipeTags.includes(tag)) {
this.importManager.recipeTags.push(tag);
this.updateTagsDisplay();
}
tagInput.value = '';
}
removeTag(tag) {
this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag);
this.updateTagsDisplay();
}
updateTagsDisplay() {
const tagsContainer = document.getElementById('tagsContainer');
if (this.importManager.recipeTags.length === 0) {
tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
return;
}
tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
<div class="recipe-tag">
${tag}
@@ -410,7 +421,7 @@ export class RecipeDataManager {
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
// Automatically mark all deleted LoRAs as excluded
if (this.importManager.recipeData && this.importManager.recipeData.loras) {
this.importManager.recipeData.loras.forEach(lora => {
@@ -419,11 +430,11 @@ export class RecipeDataManager {
}
});
}
// Update missing LoRAs list to exclude deleted LoRAs
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
!lora.existsLocally && !lora.isDeleted);
// If we have downloadable missing LoRAs, go to location step
if (this.importManager.missingLoras.length > 0) {
// Store only downloadable LoRAs for the download step

View File

@@ -2,31 +2,60 @@
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { getCurrentPageState } from './state/index.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes } from './api/recipeApi.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
class RecipePageControls {
constructor() {
this.pageType = 'recipes';
this.pageState = getCurrentPageState();
this.sidebarApiClient = new RecipeSidebarApiClient();
}
async resetAndReload() {
refreshVirtualScroll();
}
async refreshModels(fullRebuild = false) {
if (fullRebuild) {
await refreshRecipes();
return;
}
refreshVirtualScroll();
}
getSidebarApiClient() {
return this.sidebarApiClient;
}
}
class RecipeManager {
constructor() {
// Get page state
this.pageState = getCurrentPageState();
// 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,
@@ -35,27 +64,40 @@ 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();
}
async _initSidebar() {
try {
sidebarManager.setHostPageControls(this.pageControls);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
} catch (error) {
console.error('Failed to initialize recipe sidebar:', error);
}
}
_initSearchOptions() {
// Ensure recipes search options are properly initialized
if (!this.pageState.searchOptions) {
@@ -63,25 +105,27 @@ class RecipeManager {
title: true, // Recipe title
tags: true, // Recipe tags
loraName: true, // LoRA file name
loraModel: true // LoRA model name
loraModel: true, // LoRA model name
prompt: true, // Prompt search
recursive: true
};
}
}
_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 = {
@@ -90,35 +134,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 = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
} else {
filterText = 'Filtered recipes';
}
// Update indicator text and show it
textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip
@@ -126,14 +170,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) {
@@ -143,7 +187,7 @@ class RecipeManager {
});
}
}
_clearCustomFilter() {
// Reset custom filter
this.pageState.customFilter = {
@@ -152,33 +196,48 @@ 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');
if (sortSelect) {
sortSelect.value = this.pageState.sortBy || 'date:desc';
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
refreshVirtualScroll();
});
}
const bulkButton = document.querySelector('[data-action="bulk"]');
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
@@ -186,32 +245,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();
}
@@ -219,14 +278,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();
}
}
@@ -235,11 +294,11 @@ class RecipeManager {
document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application
await appCore.initialize();
// Initialize recipe manager
const recipeManager = new RecipeManager();
await recipeManager.initialize();
});
// Export for use in other modules
export { RecipeManager };
export { RecipeManager };

View File

@@ -58,7 +58,7 @@ export const state = {
loadingManager: null,
observer: null,
},
// Page-specific states
pages: {
[MODEL_TYPES.LORA]: {
@@ -69,20 +69,20 @@ export const state = {
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
activeLetterFilter: null,
previewVersions: loraPreviewVersions,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
bulkMode: false,
selectedLoras: new Set(),
loraMetadataCache: new Map(),
@@ -90,33 +90,35 @@ export const state = {
showUpdateAvailableOnly: false,
duplicatesMode: false,
},
recipes: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'date',
searchManager: null,
searchOptions: {
title: true,
tags: true,
loraName: true,
loraModel: true
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search: ''
},
sortBy: 'date:desc',
activeFolder: getStorageItem('recipes_activeFolder'),
searchManager: null,
searchOptions: {
title: true,
tags: true,
loraName: true,
loraModel: true,
recursive: getStorageItem('recipes_recursiveSearch', true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search: ''
},
pageSize: 20,
showFavoritesOnly: false,
duplicatesMode: false,
bulkMode: false,
selectedModels: new Set(),
},
[MODEL_TYPES.CHECKPOINT]: {
currentPage: 1,
isLoading: false,
@@ -124,19 +126,19 @@ export const state = {
sortBy: 'name',
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
previewVersions: checkpointPreviewVersions,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false,
selectedModels: new Set(),
@@ -145,7 +147,7 @@ export const state = {
showUpdateAvailableOnly: false,
duplicatesMode: false,
},
[MODEL_TYPES.EMBEDDING]: {
currentPage: 1,
isLoading: false,
@@ -154,20 +156,20 @@ export const state = {
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
activeLetterFilter: null,
previewVersions: embeddingPreviewVersions,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: []
},
bulkMode: false,
selectedModels: new Set(),
metadataCache: new Map(),
@@ -176,45 +178,45 @@ export const state = {
duplicatesMode: false,
}
},
// Current active page - use MODEL_TYPES constants
currentPageType: MODEL_TYPES.LORA,
// Backward compatibility - proxy properties
get currentPage() { return this.pages[this.currentPageType].currentPage; },
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
get isLoading() { return this.pages[this.currentPageType].isLoading; },
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
get hasMore() { return this.pages[this.currentPageType].hasMore; },
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
get sortBy() { return this.pages[this.currentPageType].sortBy; },
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
get loadingManager() { return this.global.loadingManager; },
set loadingManager(value) { this.global.loadingManager = value; },
get observer() { return this.global.observer; },
set observer(value) { this.global.observer = value; },
get previewVersions() { return this.pages.loras.previewVersions; },
set previewVersions(value) { this.pages.loras.previewVersions = value; },
get searchManager() { return this.pages[this.currentPageType].searchManager; },
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
get filters() { return this.pages[this.currentPageType].filters; },
set filters(value) { this.pages[this.currentPageType].filters = value; },
get bulkMode() {
get bulkMode() {
const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) {
return this.pages.loras.bulkMode;
@@ -222,7 +224,7 @@ export const state = {
return this.pages[currentType].bulkMode;
}
},
set bulkMode(value) {
set bulkMode(value) {
const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) {
this.pages.loras.bulkMode = value;
@@ -230,11 +232,11 @@ export const state = {
this.pages[currentType].bulkMode = value;
}
},
get selectedLoras() { return this.pages.loras.selectedLoras; },
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
get selectedModels() {
get selectedModels() {
const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) {
return this.pages.loras.selectedLoras;
@@ -242,7 +244,7 @@ export const state = {
return this.pages[currentType].selectedModels;
}
},
set selectedModels(value) {
set selectedModels(value) {
const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) {
this.pages.loras.selectedLoras = value;
@@ -250,10 +252,10 @@ export const state = {
this.pages[currentType].selectedModels = value;
}
},
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
get settings() { return this.global.settings; },
set settings(value) { this.global.settings = value; }
};

View File

@@ -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 = `
<p>No recipes found</p>
@@ -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();
}