mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
Merge branch 'sort-by-usage-count' into main
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.modal-header-row {
|
||||
width: 85%;
|
||||
width: 84%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -122,6 +122,7 @@ body.modal-open {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user