mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 22:52:12 -03:00
@@ -1,12 +1,13 @@
|
|||||||
/* 卡片网格布局 */
|
/* 卡片网格布局 */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||||
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
|
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||||
|
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||||
max-width: 1400px; /* Container width control */
|
max-width: 1400px; /* Base container width */
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
@@ -17,13 +18,14 @@
|
|||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
aspect-ratio: 896/1152;
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
max-width: 260px; /* Base size */
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer; /* Added from recipe-card */
|
cursor: pointer;
|
||||||
display: flex; /* Added from recipe-card */
|
display: flex;
|
||||||
flex-direction: column; /* Added from recipe-card */
|
flex-direction: column;
|
||||||
overflow: hidden; /* Add overflow hidden to contain children */
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lora-card:hover {
|
.lora-card:hover {
|
||||||
@@ -36,6 +38,30 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for 1440p screens (2K) */
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.card-grid {
|
||||||
|
max-width: 1800px; /* Increased for 2K screens */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-card {
|
||||||
|
max-width: 270px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for 4K screens */
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.card-grid {
|
||||||
|
max-width: 2400px; /* Increased for 4K screens */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-card {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
@@ -362,4 +388,42 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--lora-surface-alt);
|
background: var(--lora-surface-alt);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Virtual scrolling specific styles - updated */
|
||||||
|
.virtual-scroll-item {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 160ms ease-out;
|
||||||
|
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
||||||
|
width: 100%; /* Allow width to be set by the VirtualScroller */
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroll-item:hover {
|
||||||
|
transform: translateY(-2px); /* Keep hover effect */
|
||||||
|
z-index: 1; /* Ensure hovered items appear above others */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When using virtual scroll, adjust container */
|
||||||
|
.card-grid.virtual-scroll {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 6px 0; /* Add top/bottom padding equivalent to card padding */
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px; /* Keep the max-width from original grid */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For larger screens, allow more space for the cards */
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.card-grid.virtual-scroll {
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.card-grid.virtual-scroll {
|
||||||
|
max-width: 2400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,19 @@
|
|||||||
z-index: var(--z-base);
|
z-index: var(--z-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive container for larger screens */
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.container {
|
||||||
|
max-width: 2400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
|
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js';
|
|
||||||
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +159,231 @@ export async function loadMoreModels(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New method for virtual scrolling fetch
|
||||||
|
export async function fetchModelsPage(options = {}) {
|
||||||
|
const {
|
||||||
|
modelType = 'lora',
|
||||||
|
page = 1,
|
||||||
|
pageSize = 100,
|
||||||
|
endpoint = '/api/loras'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page,
|
||||||
|
page_size: pageSize || pageState.pageSize || 20,
|
||||||
|
sort_by: pageState.sortBy
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageState.activeFolder !== null) {
|
||||||
|
params.append('folder', pageState.activeFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add favorites filter parameter if enabled
|
||||||
|
if (pageState.showFavoritesOnly) {
|
||||||
|
params.append('favorites_only', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add active letter filter if set
|
||||||
|
if (pageState.activeLetterFilter) {
|
||||||
|
params.append('first_letter', pageState.activeLetterFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search parameters if there's a search term
|
||||||
|
if (pageState.filters?.search) {
|
||||||
|
params.append('search', pageState.filters.search);
|
||||||
|
params.append('fuzzy', 'true');
|
||||||
|
|
||||||
|
// Add search option parameters if available
|
||||||
|
if (pageState.searchOptions) {
|
||||||
|
params.append('search_filename', pageState.searchOptions.filename.toString());
|
||||||
|
params.append('search_modelname', pageState.searchOptions.modelname.toString());
|
||||||
|
if (pageState.searchOptions.tags !== undefined) {
|
||||||
|
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||||
|
}
|
||||||
|
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter parameters if active
|
||||||
|
if (pageState.filters) {
|
||||||
|
// Handle tags filters
|
||||||
|
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||||
|
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
|
||||||
|
if (modelType === 'checkpoint') {
|
||||||
|
pageState.filters.tags.forEach(tag => {
|
||||||
|
params.append('tag', tag);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
params.append('tags', pageState.filters.tags.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle base model filters
|
||||||
|
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||||
|
if (modelType === 'checkpoint') {
|
||||||
|
pageState.filters.baseModel.forEach(model => {
|
||||||
|
params.append('base_model', model);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add model-specific parameters
|
||||||
|
if (modelType === 'lora') {
|
||||||
|
// Check for recipe-based filtering parameters from session storage
|
||||||
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
|
|
||||||
|
// Add hash filter parameter if present
|
||||||
|
if (filterLoraHash) {
|
||||||
|
params.append('lora_hash', filterLoraHash);
|
||||||
|
}
|
||||||
|
// Add multiple hashes filter if present
|
||||||
|
else if (filterLoraHashes) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||||
|
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing lora hashes from session storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${endpoint}?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: data.items,
|
||||||
|
totalItems: data.total,
|
||||||
|
totalPages: data.total_pages,
|
||||||
|
currentPage: page,
|
||||||
|
hasMore: page < data.total_pages,
|
||||||
|
folders: data.folders
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${modelType}s:`, error);
|
||||||
|
showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset and reload models using virtual scrolling
|
||||||
|
* @param {Object} options - Operation options
|
||||||
|
* @returns {Promise<Object>} The fetch result
|
||||||
|
*/
|
||||||
|
export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||||
|
const {
|
||||||
|
modelType = 'lora',
|
||||||
|
updateFolders = false,
|
||||||
|
fetchPageFunction
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
pageState.isLoading = true;
|
||||||
|
document.body.classList.add('loading');
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Update folders if needed
|
||||||
|
if (updateFolders && result.folders) {
|
||||||
|
updateFolderTags(result.folders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reloading ${modelType}s:`, error);
|
||||||
|
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
pageState.isLoading = false;
|
||||||
|
document.body.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more models using virtual scrolling
|
||||||
|
* @param {Object} options - Operation options
|
||||||
|
* @returns {Promise<Object>} The fetch result
|
||||||
|
*/
|
||||||
|
export async function loadMoreWithVirtualScroll(options = {}) {
|
||||||
|
const {
|
||||||
|
modelType = 'lora',
|
||||||
|
resetPage = false,
|
||||||
|
updateFolders = false,
|
||||||
|
fetchPageFunction
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start loading state
|
||||||
|
pageState.isLoading = true;
|
||||||
|
document.body.classList.add('loading');
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Update folders if needed
|
||||||
|
if (updateFolders && result.folders) {
|
||||||
|
updateFolderTags(result.folders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading ${modelType}s:`, error);
|
||||||
|
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
pageState.isLoading = false;
|
||||||
|
document.body.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update folder tags in the UI
|
// Update folder tags in the UI
|
||||||
export function updateFolderTags(folders) {
|
export function updateFolderTags(folders) {
|
||||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
const folderTagsContainer = document.querySelector('.folder-tags');
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
||||||
import {
|
import {
|
||||||
loadMoreModels,
|
loadMoreModels,
|
||||||
|
fetchModelsPage,
|
||||||
resetAndReload as baseResetAndReload,
|
resetAndReload as baseResetAndReload,
|
||||||
|
resetAndReloadWithVirtualScroll,
|
||||||
|
loadMoreWithVirtualScroll,
|
||||||
refreshModels as baseRefreshModels,
|
refreshModels as baseRefreshModels,
|
||||||
deleteModel as baseDeleteModel,
|
deleteModel as baseDeleteModel,
|
||||||
replaceModelPreview,
|
replaceModelPreview,
|
||||||
@@ -9,25 +12,67 @@ import {
|
|||||||
refreshSingleModelMetadata,
|
refreshSingleModelMetadata,
|
||||||
excludeModel as baseExcludeModel
|
excludeModel as baseExcludeModel
|
||||||
} from './baseModelApi.js';
|
} from './baseModelApi.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
// Load more checkpoints with pagination
|
/**
|
||||||
export async function loadMoreCheckpoints(resetPagination = true) {
|
* Fetch checkpoints with pagination for virtual scrolling
|
||||||
return loadMoreModels({
|
* @param {number} page - Page number to fetch
|
||||||
resetPage: resetPagination,
|
* @param {number} pageSize - Number of items per page
|
||||||
updateFolders: true,
|
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||||
|
*/
|
||||||
|
export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
|
||||||
|
return fetchModelsPage({
|
||||||
modelType: 'checkpoint',
|
modelType: 'checkpoint',
|
||||||
createCardFunction: createCheckpointCard,
|
page,
|
||||||
|
pageSize,
|
||||||
endpoint: '/api/checkpoints'
|
endpoint: '/api/checkpoints'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more checkpoints with pagination - updated to work with VirtualScroller
|
||||||
|
* @param {boolean} resetPage - Whether to reset to the first page
|
||||||
|
* @param {boolean} updateFolders - Whether to update folder tags
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
|
||||||
|
// Check if virtual scroller is available
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
return loadMoreWithVirtualScroll({
|
||||||
|
modelType: 'checkpoint',
|
||||||
|
resetPage,
|
||||||
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchCheckpointsPage
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to the original implementation if virtual scroller isn't available
|
||||||
|
return loadMoreModels({
|
||||||
|
resetPage,
|
||||||
|
updateFolders,
|
||||||
|
modelType: 'checkpoint',
|
||||||
|
createCardFunction: createCheckpointCard,
|
||||||
|
endpoint: '/api/checkpoints'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset and reload checkpoints
|
// Reset and reload checkpoints
|
||||||
export async function resetAndReload() {
|
export async function resetAndReload(updateFolders = false) {
|
||||||
return baseResetAndReload({
|
// Check if virtual scroller is available
|
||||||
updateFolders: true,
|
if (state.virtualScroller) {
|
||||||
modelType: 'checkpoint',
|
return resetAndReloadWithVirtualScroll({
|
||||||
loadMoreFunction: loadMoreCheckpoints
|
modelType: 'checkpoint',
|
||||||
});
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchCheckpointsPage
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to original implementation
|
||||||
|
return baseResetAndReload({
|
||||||
|
updateFolders,
|
||||||
|
modelType: 'checkpoint',
|
||||||
|
loadMoreFunction: loadMoreCheckpoints
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh checkpoints
|
// Refresh checkpoints
|
||||||
@@ -60,7 +105,11 @@ export async function fetchCivitai() {
|
|||||||
|
|
||||||
// Refresh single checkpoint metadata
|
// Refresh single checkpoint metadata
|
||||||
export async function refreshSingleCheckpointMetadata(filePath) {
|
export async function refreshSingleCheckpointMetadata(filePath) {
|
||||||
return refreshSingleModelMetadata(filePath, 'checkpoint');
|
const success = await refreshSingleModelMetadata(filePath, 'checkpoint');
|
||||||
|
if (success) {
|
||||||
|
// Reload the current view to show updated data
|
||||||
|
await resetAndReload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createLoraCard } from '../components/LoraCard.js';
|
import { createLoraCard } from '../components/LoraCard.js';
|
||||||
import {
|
import {
|
||||||
loadMoreModels,
|
loadMoreModels,
|
||||||
|
fetchModelsPage,
|
||||||
resetAndReload as baseResetAndReload,
|
resetAndReload as baseResetAndReload,
|
||||||
|
resetAndReloadWithVirtualScroll,
|
||||||
|
loadMoreWithVirtualScroll,
|
||||||
refreshModels as baseRefreshModels,
|
refreshModels as baseRefreshModels,
|
||||||
deleteModel as baseDeleteModel,
|
deleteModel as baseDeleteModel,
|
||||||
replaceModelPreview,
|
replaceModelPreview,
|
||||||
@@ -9,6 +12,8 @@ import {
|
|||||||
refreshSingleModelMetadata,
|
refreshSingleModelMetadata,
|
||||||
excludeModel as baseExcludeModel
|
excludeModel as baseExcludeModel
|
||||||
} from './baseModelApi.js';
|
} from './baseModelApi.js';
|
||||||
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save model metadata to the server
|
* Save model metadata to the server
|
||||||
@@ -44,12 +49,46 @@ export async function excludeLora(filePath) {
|
|||||||
return baseExcludeModel(filePath, 'lora');
|
return baseExcludeModel(filePath, 'lora');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more loras with pagination - updated to work with VirtualScroller
|
||||||
|
* @param {boolean} resetPage - Whether to reset to the first page
|
||||||
|
* @param {boolean} updateFolders - Whether to update folder tags
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||||
return loadMoreModels({
|
const pageState = getCurrentPageState();
|
||||||
resetPage,
|
|
||||||
updateFolders,
|
// Check if virtual scroller is available
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
return loadMoreWithVirtualScroll({
|
||||||
|
modelType: 'lora',
|
||||||
|
resetPage,
|
||||||
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchLorasPage
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to the original implementation if virtual scroller isn't available
|
||||||
|
return loadMoreModels({
|
||||||
|
resetPage,
|
||||||
|
updateFolders,
|
||||||
|
modelType: 'lora',
|
||||||
|
createCardFunction: createLoraCard,
|
||||||
|
endpoint: '/api/loras'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch loras with pagination for virtual scrolling
|
||||||
|
* @param {number} page - Page number to fetch
|
||||||
|
* @param {number} pageSize - Number of items per page
|
||||||
|
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||||
|
*/
|
||||||
|
export async function fetchLorasPage(page = 1, pageSize = 100) {
|
||||||
|
return fetchModelsPage({
|
||||||
modelType: 'lora',
|
modelType: 'lora',
|
||||||
createCardFunction: createLoraCard,
|
page,
|
||||||
|
pageSize,
|
||||||
endpoint: '/api/loras'
|
endpoint: '/api/loras'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,21 +110,38 @@ export async function replacePreview(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function appendLoraCards(loras) {
|
export function appendLoraCards(loras) {
|
||||||
const grid = document.getElementById('loraGrid');
|
// This function is no longer needed with virtual scrolling
|
||||||
const sentinel = document.getElementById('scroll-sentinel');
|
// but kept for compatibility
|
||||||
|
if (state.virtualScroller) {
|
||||||
loras.forEach(lora => {
|
console.warn('appendLoraCards is deprecated when using virtual scrolling');
|
||||||
const card = createLoraCard(lora);
|
} else {
|
||||||
grid.appendChild(card);
|
const grid = document.getElementById('loraGrid');
|
||||||
});
|
|
||||||
|
loras.forEach(lora => {
|
||||||
|
const card = createLoraCard(lora);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAndReload(updateFolders = false) {
|
export async function resetAndReload(updateFolders = false) {
|
||||||
return baseResetAndReload({
|
const pageState = getCurrentPageState();
|
||||||
updateFolders,
|
|
||||||
modelType: 'lora',
|
// Check if virtual scroller is available
|
||||||
loadMoreFunction: loadMoreLoras
|
if (state.virtualScroller) {
|
||||||
});
|
return resetAndReloadWithVirtualScroll({
|
||||||
|
modelType: 'lora',
|
||||||
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchLorasPage
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to original implementation
|
||||||
|
return baseResetAndReload({
|
||||||
|
updateFolders,
|
||||||
|
modelType: 'lora',
|
||||||
|
loadMoreFunction: loadMoreLoras
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshLoras() {
|
export async function refreshLoras() {
|
||||||
|
|||||||
174
static/js/api/recipeApi.js
Normal file
174
static/js/api/recipeApi.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { RecipeCard } from '../components/RecipeCard.js';
|
||||||
|
import {
|
||||||
|
fetchModelsPage,
|
||||||
|
resetAndReloadWithVirtualScroll,
|
||||||
|
loadMoreWithVirtualScroll
|
||||||
|
} from './baseModelApi.js';
|
||||||
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
|
* @param {number} page - Page number to fetch
|
||||||
|
* @param {number} pageSize - Number of items per page
|
||||||
|
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||||
|
*/
|
||||||
|
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 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/recipe/${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],
|
||||||
|
totalItems: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
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('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 && pageState.filters.tags.length) {
|
||||||
|
params.append('tags', pageState.filters.tags.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recipes
|
||||||
|
const response = await fetch(`/api/recipes?${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,
|
||||||
|
totalPages: data.total_pages,
|
||||||
|
currentPage: page,
|
||||||
|
hasMore: page < data.total_pages
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recipes:', error);
|
||||||
|
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset and reload recipes using virtual scrolling
|
||||||
|
* @param {boolean} updateFolders - Whether to update folder tags
|
||||||
|
* @returns {Promise<Object>} The fetch result
|
||||||
|
*/
|
||||||
|
export async function resetAndReload(updateFolders = false) {
|
||||||
|
return resetAndReloadWithVirtualScroll({
|
||||||
|
modelType: 'recipe',
|
||||||
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchRecipesPage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||||
|
*/
|
||||||
|
export async function refreshRecipes() {
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||||
|
|
||||||
|
// Call the API endpoint to rebuild the recipe cache
|
||||||
|
const response = await fetch('/api/recipes/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('Refresh complete', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing recipes:', error);
|
||||||
|
showToast(error.message || 'Failed to refresh recipes', 'error');
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
state.loadingManager.restoreProgressBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more recipes with pagination - updated to work with VirtualScroller
|
||||||
|
* @param {boolean} resetPage - Whether to reset to the first page
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function loadMoreRecipes(resetPage = false) {
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Use virtual scroller if available
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
return loadMoreWithVirtualScroll({
|
||||||
|
modelType: 'recipe',
|
||||||
|
resetPage,
|
||||||
|
updateFolders: false,
|
||||||
|
fetchPageFunction: fetchRecipesPage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a recipe card instance from recipe data
|
||||||
|
* @param {Object} recipe - Recipe data
|
||||||
|
* @returns {HTMLElement} Recipe card DOM element
|
||||||
|
*/
|
||||||
|
export function createRecipeCard(recipe) {
|
||||||
|
const recipeCard = new RecipeCard(recipe, (recipe) => {
|
||||||
|
if (window.recipeManager) {
|
||||||
|
window.recipeManager.showRecipeDetails(recipe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return recipeCard.element;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
||||||
@@ -40,9 +39,6 @@ class CheckpointsPageManager {
|
|||||||
// Initialize context menu
|
// Initialize context menu
|
||||||
new CheckpointContextMenu();
|
new CheckpointContextMenu();
|
||||||
|
|
||||||
// Initialize infinite scroll
|
|
||||||
initializeInfiniteScroll('checkpoints');
|
|
||||||
|
|
||||||
// Initialize common page features
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Duplicates Manager Component
|
// Duplicates Manager Component
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.js';
|
import { RecipeCard } from './RecipeCard.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||||
|
|
||||||
export class DuplicatesManager {
|
export class DuplicatesManager {
|
||||||
@@ -61,10 +61,9 @@ export class DuplicatesManager {
|
|||||||
banner.style.display = 'block';
|
banner.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable infinite scroll
|
// Disable virtual scrolling if active
|
||||||
if (this.recipeManager.observer) {
|
if (state.virtualScroller) {
|
||||||
this.recipeManager.observer.disconnect();
|
state.virtualScroller.disable();
|
||||||
this.recipeManager.observer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add duplicate-mode class to the body
|
// Add duplicate-mode class to the body
|
||||||
@@ -94,13 +93,21 @@ export class DuplicatesManager {
|
|||||||
// Remove duplicate-mode class from the body
|
// Remove duplicate-mode class from the body
|
||||||
document.body.classList.remove('duplicate-mode');
|
document.body.classList.remove('duplicate-mode');
|
||||||
|
|
||||||
// Reload normal recipes view
|
// Clear the recipe grid first
|
||||||
this.recipeManager.loadRecipes();
|
const recipeGrid = document.getElementById('recipeGrid');
|
||||||
|
if (recipeGrid) {
|
||||||
|
recipeGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Reinitialize infinite scroll
|
// Re-enable virtual scrolling
|
||||||
setTimeout(() => {
|
if (state.virtualScroller) {
|
||||||
initializeInfiniteScroll('recipes');
|
state.virtualScroller.enable();
|
||||||
}, 500);
|
} else {
|
||||||
|
// If virtual scroller doesn't exist, reinitialize it
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeInfiniteScroll('recipes');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDuplicateGroups() {
|
renderDuplicateGroups() {
|
||||||
|
|||||||
@@ -6,6 +6,181 @@ import { NSFW_LEVELS } from '../utils/constants.js';
|
|||||||
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
|
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
|
||||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||||
|
|
||||||
|
// Add a global event delegation handler
|
||||||
|
export function setupLoraCardEventDelegation() {
|
||||||
|
const gridElement = document.getElementById('loraGrid');
|
||||||
|
if (!gridElement) return;
|
||||||
|
|
||||||
|
// Remove any existing event listener to prevent duplication
|
||||||
|
gridElement.removeEventListener('click', handleLoraCardEvent);
|
||||||
|
|
||||||
|
// Add the event delegation handler
|
||||||
|
gridElement.addEventListener('click', handleLoraCardEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation handler for all lora card events
|
||||||
|
function handleLoraCardEvent(event) {
|
||||||
|
// Find the closest card element
|
||||||
|
const card = event.target.closest('.lora-card');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
// Handle specific elements within the card
|
||||||
|
if (event.target.closest('.toggle-blur-btn')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleBlurContent(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.show-content-btn')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
showBlurredContent(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.fa-star')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFavorite(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.fa-globe')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (card.dataset.from_civitai === 'true') {
|
||||||
|
openCivitai(card.dataset.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.fa-copy')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
copyLoraCode(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.fa-trash')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
showDeleteModal(card.dataset.filepath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('.fa-image')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
replacePreview(card.dataset.filepath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||||
|
if (state.bulkMode) {
|
||||||
|
// Toggle selection using the bulk manager
|
||||||
|
bulkManager.toggleCardSelection(card);
|
||||||
|
} else {
|
||||||
|
// Normal behavior - show modal
|
||||||
|
const loraMeta = {
|
||||||
|
sha256: card.dataset.sha256,
|
||||||
|
file_path: card.dataset.filepath,
|
||||||
|
model_name: card.dataset.name,
|
||||||
|
file_name: card.dataset.file_name,
|
||||||
|
folder: card.dataset.folder,
|
||||||
|
modified: card.dataset.modified,
|
||||||
|
file_size: card.dataset.file_size,
|
||||||
|
from_civitai: card.dataset.from_civitai === 'true',
|
||||||
|
base_model: card.dataset.base_model,
|
||||||
|
usage_tips: card.dataset.usage_tips,
|
||||||
|
notes: card.dataset.notes,
|
||||||
|
favorite: card.dataset.favorite === 'true',
|
||||||
|
// Parse civitai metadata from the card's dataset
|
||||||
|
civitai: (() => {
|
||||||
|
try {
|
||||||
|
// Attempt to parse the JSON string
|
||||||
|
return JSON.parse(card.dataset.meta || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse civitai metadata:', e);
|
||||||
|
return {}; // Return empty object on error
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||||
|
modelDescription: card.dataset.modelDescription || ''
|
||||||
|
};
|
||||||
|
showLoraModal(loraMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for event handling
|
||||||
|
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) {
|
||||||
|
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(card) {
|
||||||
|
const starIcon = card.querySelector('.fa-star');
|
||||||
|
const isFavorite = starIcon.classList.contains('fas');
|
||||||
|
const newFavoriteState = !isFavorite;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save the new favorite state to the server
|
||||||
|
await saveModelMetadata(card.dataset.filepath, {
|
||||||
|
favorite: newFavoriteState
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the UI
|
||||||
|
if (newFavoriteState) {
|
||||||
|
starIcon.classList.remove('far');
|
||||||
|
starIcon.classList.add('fas', 'favorite-active');
|
||||||
|
starIcon.title = 'Remove from favorites';
|
||||||
|
card.dataset.favorite = 'true';
|
||||||
|
showToast('Added to favorites', 'success');
|
||||||
|
} else {
|
||||||
|
starIcon.classList.remove('fas', 'favorite-active');
|
||||||
|
starIcon.classList.add('far');
|
||||||
|
starIcon.title = 'Add to favorites';
|
||||||
|
card.dataset.favorite = 'false';
|
||||||
|
showToast('Removed from favorites', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update favorite status:', error);
|
||||||
|
showToast('Failed to update favorite status', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLoraCode(card) {
|
||||||
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
|
const strength = usageTips.strength || 1;
|
||||||
|
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||||
|
|
||||||
|
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
|
||||||
|
}
|
||||||
|
|
||||||
export function createLoraCard(lora) {
|
export function createLoraCard(lora) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'lora-card';
|
card.className = 'lora-card';
|
||||||
@@ -123,162 +298,12 @@ export function createLoraCard(lora) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Main card click event - modified to handle bulk mode
|
// Add a special class for virtual scroll positioning if needed
|
||||||
card.addEventListener('click', () => {
|
if (state.virtualScroller) {
|
||||||
// Check if we're in bulk mode
|
card.classList.add('virtual-scroll-item');
|
||||||
if (state.bulkMode) {
|
|
||||||
// Toggle selection using the bulk manager
|
|
||||||
bulkManager.toggleCardSelection(card);
|
|
||||||
} else {
|
|
||||||
// Normal behavior - show modal
|
|
||||||
const loraMeta = {
|
|
||||||
sha256: card.dataset.sha256,
|
|
||||||
file_path: card.dataset.filepath,
|
|
||||||
model_name: card.dataset.name,
|
|
||||||
file_name: card.dataset.file_name,
|
|
||||||
folder: card.dataset.folder,
|
|
||||||
modified: card.dataset.modified,
|
|
||||||
file_size: card.dataset.file_size,
|
|
||||||
from_civitai: card.dataset.from_civitai === 'true',
|
|
||||||
base_model: card.dataset.base_model,
|
|
||||||
usage_tips: card.dataset.usage_tips,
|
|
||||||
notes: card.dataset.notes,
|
|
||||||
favorite: card.dataset.favorite === 'true',
|
|
||||||
// Parse civitai metadata from the card's dataset
|
|
||||||
civitai: (() => {
|
|
||||||
try {
|
|
||||||
// Attempt to parse the JSON string
|
|
||||||
return JSON.parse(card.dataset.meta || '{}');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse civitai metadata:', e);
|
|
||||||
return {}; // Return empty object on error
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
|
||||||
modelDescription: card.dataset.modelDescription || ''
|
|
||||||
};
|
|
||||||
showLoraModal(loraMeta);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle blur button functionality
|
|
||||||
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
|
|
||||||
if (toggleBlurBtn) {
|
|
||||||
toggleBlurBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const preview = card.querySelector('.card-preview');
|
|
||||||
const isBlurred = preview.classList.toggle('blurred');
|
|
||||||
const icon = toggleBlurBtn.querySelector('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) {
|
|
||||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show content button functionality
|
|
||||||
const showContentBtn = card.querySelector('.show-content-btn');
|
|
||||||
if (showContentBtn) {
|
|
||||||
showContentBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
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) {
|
|
||||||
overlay.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorite button click event
|
|
||||||
card.querySelector('.fa-star')?.addEventListener('click', async e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const starIcon = e.currentTarget;
|
|
||||||
const isFavorite = starIcon.classList.contains('fas');
|
|
||||||
const newFavoriteState = !isFavorite;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save the new favorite state to the server
|
|
||||||
await saveModelMetadata(card.dataset.filepath, {
|
|
||||||
favorite: newFavoriteState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the UI
|
|
||||||
if (newFavoriteState) {
|
|
||||||
starIcon.classList.remove('far');
|
|
||||||
starIcon.classList.add('fas', 'favorite-active');
|
|
||||||
starIcon.title = 'Remove from favorites';
|
|
||||||
card.dataset.favorite = 'true';
|
|
||||||
showToast('Added to favorites', 'success');
|
|
||||||
} else {
|
|
||||||
starIcon.classList.remove('fas', 'favorite-active');
|
|
||||||
starIcon.classList.add('far');
|
|
||||||
starIcon.title = 'Add to favorites';
|
|
||||||
card.dataset.favorite = 'false';
|
|
||||||
showToast('Removed from favorites', 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update favorite status:', error);
|
|
||||||
showToast('Failed to update favorite status', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy button click event
|
|
||||||
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
|
||||||
const strength = usageTips.strength || 1;
|
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
|
||||||
|
|
||||||
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Civitai button click event
|
|
||||||
if (lora.from_civitai) {
|
|
||||||
card.querySelector('.fa-globe')?.addEventListener('click', e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openCivitai(lora.model_name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete button click event
|
|
||||||
card.querySelector('.fa-trash')?.addEventListener('click', e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showDeleteModal(lora.file_path);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replace preview button click event
|
|
||||||
card.querySelector('.fa-image')?.addEventListener('click', e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
replacePreview(lora.file_path);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply bulk mode styling if currently in bulk mode
|
|
||||||
if (state.bulkMode) {
|
|
||||||
const actions = card.querySelectorAll('.card-actions');
|
|
||||||
actions.forEach(actionGroup => {
|
|
||||||
actionGroup.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add autoplayOnHover handlers for video elements if needed
|
// Add video auto-play on hover functionality if needed
|
||||||
const videoElement = card.querySelector('video');
|
const videoElement = card.querySelector('video');
|
||||||
if (videoElement && autoplayOnHover) {
|
if (videoElement && autoplayOnHover) {
|
||||||
const cardPreview = card.querySelector('.card-preview');
|
const cardPreview = card.querySelector('.card-preview');
|
||||||
@@ -287,15 +312,10 @@ export function createLoraCard(lora) {
|
|||||||
videoElement.removeAttribute('autoplay');
|
videoElement.removeAttribute('autoplay');
|
||||||
videoElement.pause();
|
videoElement.pause();
|
||||||
|
|
||||||
// Add mouse events to trigger play/pause
|
// Add mouse events to trigger play/pause using event attributes
|
||||||
cardPreview.addEventListener('mouseenter', () => {
|
// This approach reduces the number of event listeners created
|
||||||
videoElement.play();
|
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
|
||||||
});
|
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
|
||||||
|
|
||||||
cardPreview.addEventListener('mouseleave', () => {
|
|
||||||
videoElement.pause();
|
|
||||||
videoElement.currentTime = 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
@@ -308,7 +328,7 @@ export function updateCardsForBulkMode(isBulkMode) {
|
|||||||
|
|
||||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||||
|
|
||||||
// Get all lora cards
|
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
||||||
const loraCards = document.querySelectorAll('.lora-card');
|
const loraCards = document.querySelectorAll('.lora-card');
|
||||||
|
|
||||||
loraCards.forEach(card => {
|
loraCards.forEach(card => {
|
||||||
@@ -330,6 +350,11 @@ 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
|
// Apply selection state to cards if entering bulk mode
|
||||||
if (isBulkMode) {
|
if (isBulkMode) {
|
||||||
bulkManager.applySelectionState();
|
bulkManager.applySelectionState();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class PageControls {
|
|||||||
*/
|
*/
|
||||||
initializeState() {
|
initializeState() {
|
||||||
// Set default values
|
// Set default values
|
||||||
this.pageState.pageSize = 20;
|
this.pageState.pageSize = 100;
|
||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { LorasControls } from './LorasControls.js';
|
import { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
|
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
|
||||||
|
|
||||||
// Export the classes
|
// Export the classes
|
||||||
export { PageControls, LorasControls, CheckpointsControls };
|
export { PageControls, LorasControls, CheckpointsControls };
|
||||||
@@ -20,4 +21,17 @@ export function createPageControls(pageType) {
|
|||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example for a filter method:
|
||||||
|
function applyFilter(filterType, value) {
|
||||||
|
// ...existing filter logic...
|
||||||
|
|
||||||
|
// After filters are applied, refresh the virtual scroll if it exists
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
refreshVirtualScroll();
|
||||||
|
} else {
|
||||||
|
// Fall back to existing reset and reload logic
|
||||||
|
resetAndReload(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
|||||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||||
|
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
|
||||||
|
|
||||||
// Core application class
|
// Core application class
|
||||||
export class AppCore {
|
export class AppCore {
|
||||||
@@ -63,7 +64,12 @@ export class AppCore {
|
|||||||
// Initialize lazy loading for images on all pages
|
// Initialize lazy loading for images on all pages
|
||||||
lazyLoadImages();
|
lazyLoadImages();
|
||||||
|
|
||||||
// Initialize infinite scroll for pages that need it
|
// Setup event delegation for lora cards if on the loras page
|
||||||
|
if (pageType === 'loras') {
|
||||||
|
setupLoraCardEventDelegation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize virtual scroll for pages that need it
|
||||||
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ class LoraPageManager {
|
|||||||
// Initialize the bulk manager
|
// Initialize the bulk manager
|
||||||
bulkManager.initialize();
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize common page features (lazy loading, infinite scroll)
|
// Initialize common page features (virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Add virtual scroll class to grid for CSS adjustments
|
||||||
|
const loraGrid = document.getElementById('loraGrid');
|
||||||
|
if (loraGrid && state.virtualScroller) {
|
||||||
|
loraGrid.classList.add('virtual-scroll');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Recipe manager module
|
// Recipe manager module
|
||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
import { RecipeCard } from './components/RecipeCard.js';
|
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { getCurrentPageState } from './state/index.js';
|
import { getCurrentPageState, state } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
|
import { resetAndReload, refreshRecipes } from './api/recipeApi.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -27,8 +27,8 @@ class RecipeManager {
|
|||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
// Custom filter state
|
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
@@ -49,13 +49,10 @@ class RecipeManager {
|
|||||||
// Check for custom filter parameters in session storage
|
// Check for custom filter parameters in session storage
|
||||||
this._checkCustomFilter();
|
this._checkCustomFilter();
|
||||||
|
|
||||||
// Load initial set of recipes
|
|
||||||
await this.loadRecipes();
|
|
||||||
|
|
||||||
// Expose necessary functions to the page
|
// Expose necessary functions to the page
|
||||||
this._exposeGlobalFunctions();
|
this._exposeGlobalFunctions();
|
||||||
|
|
||||||
// Initialize common page features (lazy loading, infinite scroll)
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +84,7 @@ class RecipeManager {
|
|||||||
|
|
||||||
// Set custom filter if any parameter is present
|
// Set custom filter if any parameter is present
|
||||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: true,
|
active: true,
|
||||||
loraName: filterLoraName,
|
loraName: filterLoraName,
|
||||||
loraHash: filterLoraHash,
|
loraHash: filterLoraHash,
|
||||||
@@ -108,11 +105,11 @@ class RecipeManager {
|
|||||||
// Update text based on filter type
|
// Update text based on filter type
|
||||||
let filterText = '';
|
let filterText = '';
|
||||||
|
|
||||||
if (this.customFilter.recipeId) {
|
if (this.pageState.customFilter.recipeId) {
|
||||||
filterText = 'Viewing specific recipe';
|
filterText = 'Viewing specific recipe';
|
||||||
} else if (this.customFilter.loraName) {
|
} else if (this.pageState.customFilter.loraName) {
|
||||||
// Format with Lora name
|
// Format with Lora name
|
||||||
const loraName = this.customFilter.loraName;
|
const loraName = this.pageState.customFilter.loraName;
|
||||||
const displayName = loraName.length > 25 ?
|
const displayName = loraName.length > 25 ?
|
||||||
loraName.substring(0, 22) + '...' :
|
loraName.substring(0, 22) + '...' :
|
||||||
loraName;
|
loraName;
|
||||||
@@ -125,8 +122,8 @@ class RecipeManager {
|
|||||||
// Update indicator text and show it
|
// Update indicator text and show it
|
||||||
textElement.innerHTML = filterText;
|
textElement.innerHTML = filterText;
|
||||||
// Add title attribute to show the lora name as a tooltip
|
// Add title attribute to show the lora name as a tooltip
|
||||||
if (this.customFilter.loraName) {
|
if (this.pageState.customFilter.loraName) {
|
||||||
textElement.setAttribute('title', this.customFilter.loraName);
|
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||||
}
|
}
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -149,7 +146,7 @@ class RecipeManager {
|
|||||||
|
|
||||||
_clearCustomFilter() {
|
_clearCustomFilter() {
|
||||||
// Reset custom filter
|
// Reset custom filter
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
@@ -167,8 +164,8 @@ class RecipeManager {
|
|||||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
removeSessionItem('viewRecipeId');
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Reload recipes without custom filter
|
// Reset and refresh the virtual scroller
|
||||||
this.loadRecipes();
|
refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
@@ -177,105 +174,21 @@ class RecipeManager {
|
|||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
this.loadRecipes();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This method is kept for compatibility but now uses virtual scrolling
|
||||||
async loadRecipes(resetPage = true) {
|
async loadRecipes(resetPage = true) {
|
||||||
try {
|
// Skip loading if in duplicates mode
|
||||||
// Skip loading if in duplicates mode
|
const pageState = getCurrentPageState();
|
||||||
const pageState = getCurrentPageState();
|
if (pageState.duplicatesMode) {
|
||||||
if (pageState.duplicatesMode) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
if (resetPage) {
|
||||||
// Show loading indicator
|
refreshVirtualScroll();
|
||||||
document.body.classList.add('loading');
|
|
||||||
this.pageState.isLoading = true;
|
|
||||||
|
|
||||||
// Reset to first page if requested
|
|
||||||
if (resetPage) {
|
|
||||||
this.pageState.currentPage = 1;
|
|
||||||
// Clear grid if resetting
|
|
||||||
const grid = document.getElementById('recipeGrid');
|
|
||||||
if (grid) grid.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a specific recipe ID to load
|
|
||||||
if (this.customFilter.active && this.customFilter.recipeId) {
|
|
||||||
await this._loadSpecificRecipe(this.customFilter.recipeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: this.pageState.currentPage,
|
|
||||||
page_size: this.pageState.pageSize || 20,
|
|
||||||
sort_by: this.pageState.sortBy
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add custom filter for Lora if present
|
|
||||||
if (this.customFilter.active && this.customFilter.loraHash) {
|
|
||||||
params.append('lora_hash', this.customFilter.loraHash);
|
|
||||||
|
|
||||||
// Skip other filters when using custom filter
|
|
||||||
params.append('bypass_filters', 'true');
|
|
||||||
} else {
|
|
||||||
// Normal filtering logic
|
|
||||||
|
|
||||||
// Add search filter if present
|
|
||||||
if (this.pageState.filters.search) {
|
|
||||||
params.append('search', this.pageState.filters.search);
|
|
||||||
|
|
||||||
// Add search option parameters
|
|
||||||
if (this.pageState.searchOptions) {
|
|
||||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
|
||||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
|
||||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
|
||||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
|
||||||
params.append('fuzzy', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add base model filters
|
|
||||||
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
|
|
||||||
params.append('base_models', this.pageState.filters.baseModel.join(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tag filters
|
|
||||||
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
|
|
||||||
params.append('tags', this.pageState.filters.tags.join(','));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch recipes
|
|
||||||
const response = await fetch(`/api/recipes?${params.toString()}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update recipes grid
|
|
||||||
this.updateRecipesGrid(data, resetPage);
|
|
||||||
|
|
||||||
// Update pagination state based on current page and total pages
|
|
||||||
this.pageState.hasMore = data.page < data.total_pages;
|
|
||||||
|
|
||||||
// Increment the page number AFTER successful loading
|
|
||||||
if (data.items.length > 0) {
|
|
||||||
this.pageState.currentPage++;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading recipes:', error);
|
|
||||||
appCore.showToast('Failed to load recipes', 'error');
|
|
||||||
} finally {
|
|
||||||
// Hide loading indicator
|
|
||||||
document.body.classList.remove('loading');
|
|
||||||
this.pageState.isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,95 +196,7 @@ class RecipeManager {
|
|||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||||
*/
|
*/
|
||||||
async refreshRecipes() {
|
async refreshRecipes() {
|
||||||
try {
|
return refreshRecipes();
|
||||||
// Call the new endpoint to rebuild the recipe cache
|
|
||||||
const response = await fetch('/api/recipes/scan');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
|
||||||
}
|
|
||||||
|
|
||||||
// After successful cache rebuild, load the recipes
|
|
||||||
await this.loadRecipes(true);
|
|
||||||
|
|
||||||
appCore.showToast('Refresh complete', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing recipes:', error);
|
|
||||||
appCore.showToast(error.message || 'Failed to refresh recipes', 'error');
|
|
||||||
|
|
||||||
// Still try to load recipes even if scan failed
|
|
||||||
await this.loadRecipes(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadSpecificRecipe(recipeId) {
|
|
||||||
try {
|
|
||||||
// Fetch specific recipe by ID
|
|
||||||
const response = await fetch(`/api/recipe/${recipeId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipe = await response.json();
|
|
||||||
|
|
||||||
// Create a data structure that matches the expected format
|
|
||||||
const recipeData = {
|
|
||||||
items: [recipe],
|
|
||||||
total: 1,
|
|
||||||
page: 1,
|
|
||||||
page_size: 1,
|
|
||||||
total_pages: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update grid with single recipe
|
|
||||||
this.updateRecipesGrid(recipeData, true);
|
|
||||||
|
|
||||||
// Pagination not needed for single recipe
|
|
||||||
this.pageState.hasMore = false;
|
|
||||||
|
|
||||||
// Show recipe details modal
|
|
||||||
setTimeout(() => {
|
|
||||||
this.showRecipeDetails(recipe);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading specific recipe:', error);
|
|
||||||
appCore.showToast('Failed to load recipe details', 'error');
|
|
||||||
|
|
||||||
// Clear the filter and show all recipes
|
|
||||||
this._clearCustomFilter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRecipesGrid(data, resetGrid = true) {
|
|
||||||
const grid = document.getElementById('recipeGrid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
// Check if data exists and has items
|
|
||||||
if (!data.items || data.items.length === 0) {
|
|
||||||
if (resetGrid) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="placeholder-message">
|
|
||||||
<p>No recipes found</p>
|
|
||||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear grid if resetting
|
|
||||||
if (resetGrid) {
|
|
||||||
grid.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create recipe cards
|
|
||||||
data.items.forEach(recipe => {
|
|
||||||
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
|
|
||||||
grid.appendChild(recipeCard.element);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
@@ -396,8 +221,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exitDuplicateMode() {
|
exitDuplicateMode() {
|
||||||
|
// Clear the grid first to prevent showing old content temporarily
|
||||||
|
const recipeGrid = document.getElementById('recipeGrid');
|
||||||
|
if (recipeGrid) {
|
||||||
|
recipeGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
this.duplicatesManager.exitDuplicateMode();
|
this.duplicatesManager.exitDuplicateMode();
|
||||||
initializeInfiniteScroll();
|
|
||||||
|
// Use a small delay before initializing to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeInfiniteScroll('recipes');
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
717
static/js/utils/VirtualScroller.js
Normal file
717
static/js/utils/VirtualScroller.js
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
|
export class VirtualScroller {
|
||||||
|
constructor(options) {
|
||||||
|
// Configuration
|
||||||
|
this.gridElement = options.gridElement;
|
||||||
|
this.createItemFn = options.createItemFn;
|
||||||
|
this.fetchItemsFn = options.fetchItemsFn;
|
||||||
|
this.overscan = options.overscan || 5; // Extra items to render above/below viewport
|
||||||
|
this.containerElement = options.containerElement || this.gridElement.parentElement;
|
||||||
|
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.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.items = []; // All items metadata
|
||||||
|
this.renderedItems = new Map(); // Map of rendered DOM elements by index
|
||||||
|
this.totalItems = 0;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.hasMore = true;
|
||||||
|
this.lastScrollTop = 0;
|
||||||
|
this.scrollDirection = 'down';
|
||||||
|
this.lastRenderRange = { start: 0, end: 0 };
|
||||||
|
this.pendingScroll = null;
|
||||||
|
this.resizeObserver = null;
|
||||||
|
|
||||||
|
// Data windowing parameters
|
||||||
|
this.windowSize = options.windowSize || 2000; // ±1000 items from current view
|
||||||
|
this.windowPadding = options.windowPadding || 500; // Buffer before loading more
|
||||||
|
this.dataWindow = { start: 0, end: 0 }; // Current data window indices
|
||||||
|
this.absoluteWindowStart = 0; // Start index in absolute terms
|
||||||
|
this.fetchingWindow = false; // Flag to track window fetching state
|
||||||
|
|
||||||
|
// Responsive layout state
|
||||||
|
this.itemWidth = 0;
|
||||||
|
this.itemHeight = 0;
|
||||||
|
this.columnsCount = 0;
|
||||||
|
this.gridPadding = 12; // Gap between cards
|
||||||
|
this.columnGap = 12; // Horizontal gap
|
||||||
|
|
||||||
|
// Add loading timeout state
|
||||||
|
this.loadingTimeout = null;
|
||||||
|
this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.initializeContainer();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.calculateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeContainer() {
|
||||||
|
// Add virtual scroll class to grid
|
||||||
|
this.gridElement.classList.add('virtual-scroll');
|
||||||
|
|
||||||
|
// Set the container to have relative positioning
|
||||||
|
if (getComputedStyle(this.containerElement).position === 'static') {
|
||||||
|
this.containerElement.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a spacer element with the total height
|
||||||
|
this.spacerElement = document.createElement('div');
|
||||||
|
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Place the spacer inside the grid container
|
||||||
|
this.gridElement.appendChild(this.spacerElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateLayout() {
|
||||||
|
// Get container width and style information
|
||||||
|
const containerWidth = this.containerElement.clientWidth;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Calculate ideal card width based on breakpoints
|
||||||
|
let baseCardWidth = 260; // Default for 1080p
|
||||||
|
|
||||||
|
// Adjust card width based on screen width
|
||||||
|
if (window.innerWidth >= 3000) { // 4K
|
||||||
|
baseCardWidth = 280;
|
||||||
|
} else if (window.innerWidth >= 2000) { // 2K/1440p
|
||||||
|
baseCardWidth = 270;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many columns can fit
|
||||||
|
const maxGridWidth = window.innerWidth >= 3000 ? 2400 : // 4K
|
||||||
|
window.innerWidth >= 2000 ? 1800 : // 2K
|
||||||
|
1400; // 1080p
|
||||||
|
|
||||||
|
// Use the smaller of available content width or max grid width
|
||||||
|
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
||||||
|
|
||||||
|
// Calculate column count based on available width and card width
|
||||||
|
this.columnsCount = Math.max(1, Math.floor((actualGridWidth + 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);
|
||||||
|
|
||||||
|
// Log layout info
|
||||||
|
console.log('Virtual Scroll Layout:', {
|
||||||
|
containerWidth,
|
||||||
|
availableContentWidth,
|
||||||
|
actualGridWidth,
|
||||||
|
columnsCount: this.columnsCount,
|
||||||
|
itemWidth: this.itemWidth,
|
||||||
|
itemHeight: this.itemHeight,
|
||||||
|
leftOffset: this.leftOffset,
|
||||||
|
paddingLeft,
|
||||||
|
paddingRight,
|
||||||
|
rowGap: this.rowGap // Log row gap for debugging
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update grid element max-width to match available width
|
||||||
|
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||||
|
|
||||||
|
// Update spacer height
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// Re-render with new layout
|
||||||
|
this.clearRenderedItems();
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
await this.loadInitialBatch();
|
||||||
|
this.scheduleRender();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize virtual scroller:', err);
|
||||||
|
showToast('Failed to load items', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.clearLoadingTimeout(); // Clear the timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
showToast('Failed to load more items', 'error');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
pageState.isLoading = false;
|
||||||
|
this.clearLoadingTimeout(); // Clear the timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new methods for loading timeout
|
||||||
|
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) {
|
||||||
|
console.warn('Loading timeout occurred. Resetting loading state.');
|
||||||
|
this.isLoading = false;
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
pageState.isLoading = false;
|
||||||
|
}
|
||||||
|
}, this.loadingTimeoutDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLoadingTimeout() {
|
||||||
|
if (this.loadingTimeout) {
|
||||||
|
clearTimeout(this.loadingTimeout);
|
||||||
|
this.loadingTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Update spacer height to represent all items
|
||||||
|
this.spacerElement.style.height = `${totalHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
this.renderScheduled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 &&
|
||||||
|
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)) {
|
||||||
|
element.remove();
|
||||||
|
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)) {
|
||||||
|
const item = this.items[i];
|
||||||
|
const element = this.createItemElement(item, i);
|
||||||
|
fragment.appendChild(element);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRenderedItems() {
|
||||||
|
this.renderedItems.forEach(element => element.remove());
|
||||||
|
this.renderedItems.clear();
|
||||||
|
this.lastRenderRange = { start: 0, end: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshWithData(items, totalItems, hasMore) {
|
||||||
|
this.items = items || [];
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const topPos = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() {
|
||||||
|
// Determine scroll direction
|
||||||
|
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;
|
||||||
|
|
||||||
|
// If we've jumped to a position that's significantly outside our current window
|
||||||
|
// and we know there are many items, fetch a new data window
|
||||||
|
if (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
|
||||||
|
this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2)));
|
||||||
|
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;
|
||||||
|
const scrollThreshold = Math.min(
|
||||||
|
// Either trigger when within 20% of the total height from bottom
|
||||||
|
scrollHeight * 0.2,
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to fetch data for a specific window position
|
||||||
|
async fetchDataWindow(targetIndex) {
|
||||||
|
if (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 = {
|
||||||
|
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) {
|
||||||
|
console.error('Failed to fetch data window:', err);
|
||||||
|
showToast('Failed to load items at this position', 'error');
|
||||||
|
} finally {
|
||||||
|
this.fetchingWindow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to slide the data window if we're approaching its edges
|
||||||
|
async slideDataWindow() {
|
||||||
|
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 &&
|
||||||
|
this.totalItems - end < halfWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the new data window
|
||||||
|
await this.fetchDataWindow(targetIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add methods to handle placeholder display
|
||||||
|
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>
|
||||||
|
<p>Add recipe images to your recipes folder to see them here.</p>
|
||||||
|
`;
|
||||||
|
} else if (pageType === 'loras') {
|
||||||
|
placeholderText = `
|
||||||
|
<p>No LoRAs found</p>
|
||||||
|
<p>Add LoRAs to your models folder to see them here.</p>
|
||||||
|
`;
|
||||||
|
} else if (pageType === 'checkpoints') {
|
||||||
|
placeholderText = `
|
||||||
|
<p>No checkpoints found</p>
|
||||||
|
<p>Add checkpoints to your models folder to see them here.</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
placeholderText = `
|
||||||
|
<p>No items found</p>
|
||||||
|
<p>Try adjusting your search filters or add more content.</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder.innerHTML = placeholderText;
|
||||||
|
placeholder.id = 'virtualScrollPlaceholder';
|
||||||
|
|
||||||
|
// Append placeholder to the grid
|
||||||
|
this.gridElement.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNoItemsPlaceholder() {
|
||||||
|
const placeholder = document.getElementById('virtualScrollPlaceholder');
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility method for debouncing
|
||||||
|
debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function(...args) {
|
||||||
|
const context = this;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add disable method to stop rendering and events
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Show the spacer element
|
||||||
|
if (this.spacerElement) {
|
||||||
|
this.spacerElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag as enabled
|
||||||
|
this.disabled = false;
|
||||||
|
|
||||||
|
// Re-render items
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
console.log('Virtual scroller enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,53 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { loadMoreLoras } from '../api/loraApi.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
import { loadMoreCheckpoints } from '../api/checkpointApi.js';
|
import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js';
|
||||||
import { debounce } from './debounce.js';
|
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
||||||
|
import { fetchLorasPage } from '../api/loraApi.js';
|
||||||
|
import { fetchCheckpointsPage } from '../api/checkpointApi.js';
|
||||||
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
export function initializeInfiniteScroll(pageType = 'loras') {
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
// Clean up any existing observer
|
async function getCardCreator(pageType) {
|
||||||
if (state.observer) {
|
if (pageType === 'loras') {
|
||||||
state.observer.disconnect();
|
return createLoraCard;
|
||||||
|
} else if (pageType === 'recipes') {
|
||||||
|
// Import the RecipeCard module
|
||||||
|
const { RecipeCard } = await import('../components/RecipeCard.js');
|
||||||
|
|
||||||
|
// Return a wrapper function that creates a recipe card element
|
||||||
|
return (recipe) => {
|
||||||
|
const recipeCard = new RecipeCard(recipe, (recipe) => {
|
||||||
|
if (window.recipeManager) {
|
||||||
|
window.recipeManager.showRecipeDetails(recipe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return recipeCard.element;
|
||||||
|
};
|
||||||
|
} else if (pageType === 'checkpoints') {
|
||||||
|
return createCheckpointCard;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the appropriate data fetcher based on page type
|
||||||
|
async function getDataFetcher(pageType) {
|
||||||
|
if (pageType === 'loras') {
|
||||||
|
return fetchLorasPage;
|
||||||
|
} else if (pageType === 'recipes') {
|
||||||
|
// Import the recipeApi module and use the fetchRecipesPage function
|
||||||
|
const { fetchRecipesPage } = await import('../api/recipeApi.js');
|
||||||
|
return fetchRecipesPage;
|
||||||
|
} else if (pageType === 'checkpoints') {
|
||||||
|
return fetchCheckpointsPage;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeInfiniteScroll(pageType = 'loras') {
|
||||||
|
// Clean up any existing virtual scroller
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
state.virtualScroller.dispose();
|
||||||
|
state.virtualScroller = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the current page type
|
// Set the current page type
|
||||||
@@ -17,109 +58,96 @@ export function initializeInfiniteScroll(pageType = 'loras') {
|
|||||||
|
|
||||||
// Skip initializing if in duplicates mode (for recipes page)
|
// Skip initializing if in duplicates mode (for recipes page)
|
||||||
if (pageType === 'recipes' && pageState.duplicatesMode) {
|
if (pageType === 'recipes' && pageState.duplicatesMode) {
|
||||||
|
console.log('Skipping virtual scroll initialization - duplicates mode is active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the load more function and grid ID based on page type
|
// Use virtual scrolling for all page types
|
||||||
let loadMoreFunction;
|
await initializeVirtualScroll(pageType);
|
||||||
|
|
||||||
|
// Setup event delegation for lora cards if on the loras page
|
||||||
|
if (pageType === 'loras') {
|
||||||
|
setupLoraCardEventDelegation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeVirtualScroll(pageType) {
|
||||||
|
// Determine the grid ID based on page type
|
||||||
let gridId;
|
let gridId;
|
||||||
|
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'recipes':
|
case 'recipes':
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
window.recipeManager.loadRecipes(false); // false to not reset pagination
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'recipeGrid';
|
gridId = 'recipeGrid';
|
||||||
break;
|
break;
|
||||||
case 'checkpoints':
|
case 'checkpoints':
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
loadMoreCheckpoints(false); // false to not reset
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'checkpointGrid';
|
gridId = 'checkpointGrid';
|
||||||
break;
|
break;
|
||||||
case 'loras':
|
case 'loras':
|
||||||
default:
|
default:
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
loadMoreLoras(false); // false to not reset
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'loraGrid';
|
gridId = 'loraGrid';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedLoadMore = debounce(loadMoreFunction, 100);
|
|
||||||
|
|
||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
|
|
||||||
if (!grid) {
|
if (!grid) {
|
||||||
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
|
console.warn(`Grid with ID "${gridId}" not found for virtual scroll`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any existing sentinel
|
// Change this line to get the actual scrolling container
|
||||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
const scrollContainer = document.querySelector('.page-content');
|
||||||
if (existingSentinel) {
|
const gridContainer = scrollContainer.querySelector('.container');
|
||||||
existingSentinel.remove();
|
|
||||||
|
if (!gridContainer) {
|
||||||
|
console.warn('Grid container element not found for virtual scroll');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a sentinel element after the grid (not inside it)
|
try {
|
||||||
const sentinel = document.createElement('div');
|
// Get the card creator and data fetcher for this page type
|
||||||
sentinel.id = 'scroll-sentinel';
|
const createCardFn = await getCardCreator(pageType);
|
||||||
sentinel.style.width = '100%';
|
const fetchDataFn = await getDataFetcher(pageType);
|
||||||
sentinel.style.height = '20px';
|
|
||||||
sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout
|
if (!createCardFn || !fetchDataFn) {
|
||||||
|
throw new Error(`Required components not available for ${pageType} page`);
|
||||||
// Insert after grid instead of inside
|
|
||||||
grid.parentNode.insertBefore(sentinel, grid.nextSibling);
|
|
||||||
|
|
||||||
// Create observer with appropriate settings, slightly different for checkpoints page
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: 0.1,
|
|
||||||
rootMargin: pageType === 'checkpoints' ? '0px 0px 200px 0px' : '0px 0px 100px 0px'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize the observer
|
|
||||||
state.observer = new IntersectionObserver((entries) => {
|
|
||||||
const target = entries[0];
|
|
||||||
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
|
|
||||||
debouncedLoadMore();
|
|
||||||
}
|
}
|
||||||
}, observerOptions);
|
|
||||||
|
// Initialize virtual scroller with renamed container elements
|
||||||
// Start observing
|
state.virtualScroller = new VirtualScroller({
|
||||||
state.observer.observe(sentinel);
|
gridElement: grid,
|
||||||
|
containerElement: gridContainer,
|
||||||
// Clean up any existing scroll event listener
|
scrollContainer: scrollContainer,
|
||||||
if (state.scrollHandler) {
|
createItemFn: createCardFn,
|
||||||
window.removeEventListener('scroll', state.scrollHandler);
|
fetchItemsFn: fetchDataFn,
|
||||||
state.scrollHandler = null;
|
pageSize: 100,
|
||||||
|
rowGap: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the virtual scroller
|
||||||
|
await state.virtualScroller.initialize();
|
||||||
|
|
||||||
|
// Add grid class for CSS styling
|
||||||
|
grid.classList.add('virtual-scroll');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||||
|
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
|
||||||
|
|
||||||
|
// Fallback: show a message in the grid
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="placeholder-message">
|
||||||
|
<h3>Failed to initialize ${pageType}</h3>
|
||||||
|
<p>There was an error loading this page. Please try reloading.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Add a simple backup scroll handler
|
|
||||||
const handleScroll = debounce(() => {
|
// Export a method to refresh the virtual scroller when filters change
|
||||||
if (pageState.isLoading || !pageState.hasMore) return;
|
export function refreshVirtualScroll() {
|
||||||
|
if (state.virtualScroller) {
|
||||||
const sentinel = document.getElementById('scroll-sentinel');
|
state.virtualScroller.reset();
|
||||||
if (!sentinel) return;
|
state.virtualScroller.initialize();
|
||||||
|
|
||||||
const rect = sentinel.getBoundingClientRect();
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
|
|
||||||
if (rect.top < windowHeight + 200) {
|
|
||||||
debouncedLoadMore();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
state.scrollHandler = handleScroll;
|
|
||||||
window.addEventListener('scroll', state.scrollHandler);
|
|
||||||
|
|
||||||
// Clear any existing interval
|
|
||||||
if (state.scrollCheckInterval) {
|
|
||||||
clearInterval(state.scrollCheckInterval);
|
|
||||||
state.scrollCheckInterval = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,43 +77,8 @@
|
|||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div class="card-grid" id="recipeGrid">
|
||||||
{% if recipes and recipes|length > 0 %}
|
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||||
{% for recipe in recipes %}
|
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||||
<div class="lora-card" data-file-path="{{ recipe.file_path }}" data-title="{{ recipe.title }}" data-created="{{ recipe.created_date }}">
|
|
||||||
<div class="recipe-indicator" title="Recipe">R</div>
|
|
||||||
<div class="card-preview">
|
|
||||||
<img src="{{ recipe.file_url }}" alt="{{ recipe.title }}">
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="base-model-wrapper">
|
|
||||||
{% if recipe.base_model %}
|
|
||||||
<span class="base-model-label" title="{{ recipe.base_model }}">
|
|
||||||
{{ recipe.base_model }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card-actions">
|
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
|
||||||
<i class="fas fa-copy" title="Copy Recipe"></i>
|
|
||||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="model-info">
|
|
||||||
<span class="model-name">{{ recipe.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lora-count" title="Number of LoRAs in this recipe">
|
|
||||||
<i class="fas fa-layer-group"></i> {{ recipe.loras|length }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="placeholder-message">
|
|
||||||
<p>No recipes found</p>
|
|
||||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user