diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 06aff4a5..b235459f 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -125,6 +125,7 @@ class ApiRoutes: # Get filter parameters base_models = request.query.get('base_models', None) tags = request.query.get('tags', None) + favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter # New parameters for recipe filtering lora_hash = request.query.get('lora_hash', None) @@ -155,7 +156,8 @@ class ApiRoutes: base_models=filters.get('base_model', None), tags=filters.get('tags', None), search_options=search_options, - hash_filters=hash_filters + hash_filters=hash_filters, + favorites_only=favorites_only # Pass favorites_only parameter ) # Get all available folders from cache @@ -195,6 +197,7 @@ class ApiRoutes: "from_civitai": lora.get("from_civitai", True), "usage_tips": lora.get("usage_tips", ""), "notes": lora.get("notes", ""), + "favorite": lora.get("favorite", False), # Include favorite status in response "civitai": ModelRouteUtils.filter_civitai_data(lora.get("civitai", {})) } diff --git a/py/routes/checkpoints_routes.py b/py/routes/checkpoints_routes.py index fa4e20ee..46752f28 100644 --- a/py/routes/checkpoints_routes.py +++ b/py/routes/checkpoints_routes.py @@ -69,6 +69,7 @@ class CheckpointsRoutes: fuzzy_search = request.query.get('fuzzy_search', 'false').lower() == 'true' base_models = request.query.getall('base_model', []) tags = request.query.getall('tag', []) + favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # Add favorites_only parameter # Process search options search_options = { @@ -101,7 +102,8 @@ class CheckpointsRoutes: base_models=base_models, tags=tags, search_options=search_options, - hash_filters=hash_filters + hash_filters=hash_filters, + favorites_only=favorites_only # Pass favorites_only parameter ) # Format response items @@ -123,7 +125,8 @@ class CheckpointsRoutes: async def get_paginated_data(self, page, page_size, sort_by='name', folder=None, search=None, fuzzy_search=False, base_models=None, tags=None, - search_options=None, hash_filters=None): + search_options=None, hash_filters=None, + favorites_only=False): # Add favorites_only parameter with default False """Get paginated and filtered checkpoint data""" cache = await self.scanner.get_cached_data() @@ -181,6 +184,13 @@ class CheckpointsRoutes: if not cp.get('preview_nsfw_level') or cp.get('preview_nsfw_level') < NSFW_LEVELS['R'] ] + # Apply favorites filtering if enabled + if favorites_only: + filtered_data = [ + cp for cp in filtered_data + if cp.get('favorite', False) is True + ] + # Apply folder filtering if folder is not None: if search_options.get('recursive', False): @@ -276,6 +286,7 @@ class CheckpointsRoutes: "from_civitai": checkpoint.get("from_civitai", True), "notes": checkpoint.get("notes", ""), "model_type": checkpoint.get("model_type", "checkpoint"), + "favorite": checkpoint.get("favorite", False), "civitai": ModelRouteUtils.filter_civitai_data(checkpoint.get("civitai", {})) } diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index 9756e8f1..a333b2c3 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -122,7 +122,8 @@ class LoraScanner(ModelScanner): async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name', folder: str = None, search: str = None, fuzzy_search: bool = False, base_models: list = None, tags: list = None, - search_options: dict = None, hash_filters: dict = None) -> Dict: + search_options: dict = None, hash_filters: dict = None, + favorites_only: bool = False) -> Dict: """Get paginated and filtered lora data Args: @@ -136,6 +137,7 @@ class LoraScanner(ModelScanner): tags: List of tags to filter by search_options: Dictionary with search options (filename, modelname, tags, recursive) hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes) + favorites_only: Filter for favorite models only """ cache = await self.get_cached_data() @@ -194,6 +196,13 @@ class LoraScanner(ModelScanner): if not lora.get('preview_nsfw_level') or lora.get('preview_nsfw_level') < NSFW_LEVELS['R'] ] + # Apply favorites filtering if enabled + if favorites_only: + filtered_data = [ + lora for lora in filtered_data + if lora.get('favorite', False) is True + ] + # Apply folder filtering if folder is not None: if search_options.get('recursive', False): diff --git a/py/utils/models.py b/py/utils/models.py index 07e392ca..f0f7fc94 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -22,6 +22,7 @@ class BaseModelMetadata: tags: List[str] = None # Model tags modelDescription: str = "" # Full model description civitai_deleted: bool = False # Whether deleted from Civitai + favorite: bool = False # Whether the model is a favorite def __post_init__(self): # Initialize empty lists to avoid mutable default parameter issue diff --git a/static/css/components/card.css b/static/css/components/card.css index ed424827..8c172872 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -192,12 +192,43 @@ margin-left: var(--space-1); cursor: pointer; color: white; - transition: opacity 0.2s; - font-size: 0.9em; + transition: opacity 0.2s, transform 0.15s ease; + font-size: 1.0em; /* Increased from 0.9em for better visibility */ + width: 16px; /* Fixed width for consistent spacing */ + height: 16px; /* Fixed height for larger touch target */ + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: 4px; /* Add padding to increase clickable area */ + box-sizing: content-box; /* Ensure padding adds to dimensions */ + position: relative; /* For proper positioning */ + margin: 0; /* Reset margin */ +} + +.card-actions i::before { + position: absolute; /* Position the icon glyph */ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* Center the icon */ +} + +.card-actions { + display: flex; + gap: var(--space-1); /* Use gap instead of margin for spacing between icons */ + align-items: center; } .card-actions i:hover { - opacity: 0.8; + opacity: 0.9; + transform: scale(1.1); + background-color: rgba(255, 255, 255, 0.1); +} + +/* Style for active favorites */ +.favorite-active { + color: #ffc107 !important; /* Gold color for favorites */ + text-shadow: 0 0 5px rgba(255, 193, 7, 0.5); } /* 响应式设计 */ diff --git a/static/css/layout.css b/static/css/layout.css index d1aa0e1d..72d8120f 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -81,6 +81,22 @@ opacity: 1; } +/* Controls */ +.control-group button.favorite-filter { + position: relative; + overflow: hidden; +} + +.control-group button.favorite-filter.active { + background: var(--lora-accent); + color: white; +} + +.control-group button.favorite-filter i { + margin-right: 4px; + color: #ffc107; +} + /* Active state for buttons that can be toggled */ .control-group button.active { background: var(--lora-accent); diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 8471f1f8..45898601 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -45,6 +45,11 @@ export async function loadMoreModels(options = {}) { params.append('folder', pageState.activeFolder); } + // Add favorites filter parameter if enabled + if (pageState.showFavoritesOnly) { + params.append('favorites_only', 'true'); + } + // Add search parameters if there's a search term if (pageState.filters?.search) { params.append('search', pageState.filters.search); diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js index 96df010a..a07aee43 100644 --- a/static/js/components/CheckpointCard.js +++ b/static/js/components/CheckpointCard.js @@ -2,7 +2,7 @@ import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; import { showCheckpointModal } from './checkpointModal/index.js'; import { NSFW_LEVELS } from '../utils/constants.js'; -import { replaceCheckpointPreview as apiReplaceCheckpointPreview } from '../api/checkpointApi.js'; +import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js'; export function createCheckpointCard(checkpoint) { const card = document.createElement('div'); @@ -17,6 +17,7 @@ export function createCheckpointCard(checkpoint) { card.dataset.from_civitai = checkpoint.from_civitai; card.dataset.notes = checkpoint.notes || ''; card.dataset.base_model = checkpoint.base_model || 'Unknown'; + card.dataset.favorite = checkpoint.favorite ? 'true' : 'false'; // Store metadata if available if (checkpoint.civitai) { @@ -65,6 +66,9 @@ export function createCheckpointCard(checkpoint) { const isVideo = previewUrl.endsWith('.mp4'); const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; + // Get favorite status from checkpoint data + const isFavorite = checkpoint.favorite === true; + card.innerHTML = `
${isVideo ? @@ -82,6 +86,9 @@ export function createCheckpointCard(checkpoint) { ${checkpoint.base_model}
+ + @@ -198,6 +205,39 @@ export function createCheckpointCard(checkpoint) { }); } + // 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(); diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index fb97aadb..b5add51d 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -3,7 +3,7 @@ import { state } from '../state/index.js'; import { showLoraModal } from './loraModal/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { NSFW_LEVELS } from '../utils/constants.js'; -import { replacePreview, deleteModel } from '../api/loraApi.js' +import { replacePreview, deleteModel, saveModelMetadata } from '../api/loraApi.js' export function createLoraCard(lora) { const card = document.createElement('div'); @@ -20,6 +20,7 @@ export function createLoraCard(lora) { card.dataset.usage_tips = lora.usage_tips; card.dataset.notes = lora.notes; card.dataset.meta = JSON.stringify(lora.civitai || {}); + card.dataset.favorite = lora.favorite ? 'true' : 'false'; // Store tags and model description if (lora.tags && Array.isArray(lora.tags)) { @@ -65,6 +66,9 @@ export function createLoraCard(lora) { const isVideo = previewUrl.endsWith('.mp4'); const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; + // Get favorite status from the lora data + const isFavorite = lora.favorite === true; + card.innerHTML = `
${isVideo ? @@ -82,6 +86,9 @@ export function createLoraCard(lora) { ${lora.base_model}
+ + @@ -135,6 +142,7 @@ export function createLoraCard(lora) { 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 { @@ -198,6 +206,39 @@ export function createLoraCard(lora) { }); } + // 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(); diff --git a/static/js/components/controls/LorasControls.js b/static/js/components/controls/LorasControls.js index 0a45a9e6..521a1cee 100644 --- a/static/js/components/controls/LorasControls.js +++ b/static/js/components/controls/LorasControls.js @@ -2,7 +2,6 @@ import { PageControls } from './PageControls.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; -import { showToast } from '../../utils/uiHelpers.js'; /** * LorasControls class - Extends PageControls for LoRA-specific functionality diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index b30151c1..caf101d5 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -1,6 +1,6 @@ // PageControls.js - Manages controls for both LoRAs and Checkpoints pages import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; -import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js'; +import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js'; import { showToast } from '../../utils/uiHelpers.js'; /** @@ -26,6 +26,9 @@ export class PageControls { // Initialize event listeners this.initEventListeners(); + // Initialize favorites filter button state + this.initFavoritesFilter(); + console.log(`PageControls initialized for ${pageType} page`); } @@ -121,6 +124,12 @@ export class PageControls { bulkButton.addEventListener('click', () => this.toggleBulkMode()); } } + + // Favorites filter button handler + const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); + if (favoriteFilterBtn) { + favoriteFilterBtn.addEventListener('click', () => this.toggleFavoritesOnly()); + } } /** @@ -385,4 +394,50 @@ export class PageControls { showToast('Failed to clear custom filter: ' + error.message, 'error'); } } + + /** + * Initialize the favorites filter button state + */ + initFavoritesFilter() { + const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); + if (favoriteFilterBtn) { + // Get current state from session storage with page-specific key + const storageKey = `show_favorites_only_${this.pageType}`; + const showFavoritesOnly = getSessionItem(storageKey, false); + + // Update button state + if (showFavoritesOnly) { + favoriteFilterBtn.classList.add('active'); + } + + // Update app state + this.pageState.showFavoritesOnly = showFavoritesOnly; + } + } + + /** + * Toggle favorites-only filter and reload models + */ + async toggleFavoritesOnly() { + const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); + + // Toggle the filter state in storage + const storageKey = `show_favorites_only_${this.pageType}`; + const currentState = this.pageState.showFavoritesOnly; + const newState = !currentState; + + // Update session storage + setSessionItem(storageKey, newState); + + // Update state + this.pageState.showFavoritesOnly = newState; + + // Update button appearance + if (favoriteFilterBtn) { + favoriteFilterBtn.classList.toggle('active', newState); + } + + // Reload models with new filter + await this.resetAndReload(true); + } } \ No newline at end of file diff --git a/static/js/state/index.js b/static/js/state/index.js index a498e57b..23b41226 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -42,6 +42,7 @@ export const state = { bulkMode: false, selectedLoras: new Set(), loraMetadataCache: new Map(), + showFavoritesOnly: false, }, recipes: { @@ -61,7 +62,8 @@ export const state = { tags: [], search: '' }, - pageSize: 20 + pageSize: 20, + showFavoritesOnly: false, }, checkpoints: { @@ -80,7 +82,8 @@ export const state = { filters: { baseModel: [], tags: [] - } + }, + showFavoritesOnly: false, } }, diff --git a/static/vendor/font-awesome/webfonts/fa-regular-400.ttf b/static/vendor/font-awesome/webfonts/fa-regular-400.ttf new file mode 100644 index 00000000..549d68dc Binary files /dev/null and b/static/vendor/font-awesome/webfonts/fa-regular-400.ttf differ diff --git a/static/vendor/font-awesome/webfonts/fa-regular-400.woff2 b/static/vendor/font-awesome/webfonts/fa-regular-400.woff2 new file mode 100644 index 00000000..18400d7f Binary files /dev/null and b/static/vendor/font-awesome/webfonts/fa-regular-400.woff2 differ diff --git a/templates/components/controls.html b/templates/components/controls.html index 56921cd5..2236b997 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -35,6 +35,11 @@
{% endif %} +
+ +