diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 4abc368f..611f65c6 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -69,6 +69,9 @@ class ApiRoutes: # Add the new trigger words route app.router.add_post('/loramanager/get_trigger_words', routes.get_trigger_words) + # Add new endpoint for letter counts + app.router.add_get('/api/loras/letter-counts', routes.get_letter_counts) + # Add update check routes UpdateRoutes.setup_routes(app) @@ -126,6 +129,9 @@ class ApiRoutes: tags = request.query.get('tags', None) favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter + # New parameter for alphabet filtering + first_letter = request.query.get('first_letter', None) + # New parameters for recipe filtering lora_hash = request.query.get('lora_hash', None) lora_hashes = request.query.get('lora_hashes', None) @@ -156,7 +162,8 @@ class ApiRoutes: tags=filters.get('tags', None), search_options=search_options, hash_filters=hash_filters, - favorites_only=favorites_only # Pass favorites_only parameter + favorites_only=favorites_only, # Pass favorites_only parameter + first_letter=first_letter # Pass the new first_letter parameter ) # Get all available folders from cache @@ -1050,3 +1057,23 @@ class ApiRoutes: "success": False, "error": str(e) }, status=500) + + async def get_letter_counts(self, request: web.Request) -> web.Response: + """Get count of loras for each letter of the alphabet""" + try: + if self.scanner is None: + self.scanner = await ServiceRegistry.get_lora_scanner() + + # Get letter counts + letter_counts = await self.scanner.get_letter_counts() + + return web.json_response({ + 'success': True, + 'letter_counts': letter_counts + }) + except Exception as e: + logger.error(f"Error getting letter counts: {e}") + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index a333b2c3..c911d184 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -4,6 +4,7 @@ import logging import asyncio import shutil import time +import re from typing import List, Dict, Optional, Set from ..utils.models import LoraMetadata @@ -123,7 +124,7 @@ class LoraScanner(ModelScanner): 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, - favorites_only: bool = False) -> Dict: + favorites_only: bool = False, first_letter: str = None) -> Dict: """Get paginated and filtered lora data Args: @@ -138,6 +139,7 @@ class LoraScanner(ModelScanner): 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 + first_letter: Filter by first letter of model name """ cache = await self.get_cached_data() @@ -202,6 +204,10 @@ class LoraScanner(ModelScanner): lora for lora in filtered_data if lora.get('favorite', False) is True ] + + # Apply first letter filtering + if first_letter: + filtered_data = self._filter_by_first_letter(filtered_data, first_letter) # Apply folder filtering if folder is not None: @@ -273,6 +279,101 @@ class LoraScanner(ModelScanner): return result + def _filter_by_first_letter(self, data, letter): + """Filter data by first letter of model name + + Special handling: + - '#': Numbers (0-9) + - '@': Special characters (not alphanumeric) + - '漢': CJK characters + """ + filtered_data = [] + + for lora in data: + model_name = lora.get('model_name', '') + if not model_name: + continue + + first_char = model_name[0].upper() + + if letter == '#' and first_char.isdigit(): + filtered_data.append(lora) + elif letter == '@' and not first_char.isalnum(): + # Special characters (not alphanumeric) + filtered_data.append(lora) + elif letter == '漢' and self._is_cjk_character(first_char): + # CJK characters + filtered_data.append(lora) + elif letter.upper() == first_char: + # Regular alphabet matching + filtered_data.append(lora) + + return filtered_data + + def _is_cjk_character(self, char): + """Check if character is a CJK character""" + # Define Unicode ranges for CJK characters + cjk_ranges = [ + (0x4E00, 0x9FFF), # CJK Unified Ideographs + (0x3400, 0x4DBF), # CJK Unified Ideographs Extension A + (0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B + (0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C + (0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D + (0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E + (0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F + (0x30000, 0x3134F), # CJK Unified Ideographs Extension G + (0xF900, 0xFAFF), # CJK Compatibility Ideographs + (0x3300, 0x33FF), # CJK Compatibility + (0x3200, 0x32FF), # Enclosed CJK Letters and Months + (0x3100, 0x312F), # Bopomofo + (0x31A0, 0x31BF), # Bopomofo Extended + (0x3040, 0x309F), # Hiragana + (0x30A0, 0x30FF), # Katakana + (0x31F0, 0x31FF), # Katakana Phonetic Extensions + (0xAC00, 0xD7AF), # Hangul Syllables + (0x1100, 0x11FF), # Hangul Jamo + (0xA960, 0xA97F), # Hangul Jamo Extended-A + (0xD7B0, 0xD7FF), # Hangul Jamo Extended-B + ] + + code_point = ord(char) + return any(start <= code_point <= end for start, end in cjk_ranges) + + async def get_letter_counts(self): + """Get count of models for each letter of the alphabet""" + cache = await self.get_cached_data() + data = cache.sorted_by_name + + # Define letter categories + letters = { + '#': 0, # Numbers + 'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0, + 'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0, + 'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0, + 'Y': 0, 'Z': 0, + '@': 0, # Special characters + '漢': 0 # CJK characters + } + + # Count models for each letter + for lora in data: + model_name = lora.get('model_name', '') + if not model_name: + continue + + first_char = model_name[0].upper() + + if first_char.isdigit(): + letters['#'] += 1 + elif first_char in letters: + letters[first_char] += 1 + elif self._is_cjk_character(first_char): + letters['漢'] += 1 + elif not first_char.isalnum(): + letters['@'] += 1 + + return letters + async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict: """Update file paths in metadata file""" try: diff --git a/static/css/components/alphabet-bar.css b/static/css/components/alphabet-bar.css new file mode 100644 index 00000000..3601560e --- /dev/null +++ b/static/css/components/alphabet-bar.css @@ -0,0 +1,89 @@ +/* Alphabet Bar Component */ +.alphabet-bar { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: 4px; + margin: 8px 0; + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; + justify-content: center; +} + +.letter-chip { + padding: 4px 6px; + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + cursor: pointer; + min-width: 20px; + text-align: center; + font-size: 0.85em; + transition: all 0.2s ease; + border: 1px solid var(--border-color); +} + +.letter-chip:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.letter-chip.active { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +.letter-chip.disabled { + opacity: 0.5; + pointer-events: none; + cursor: default; +} + +.letter-chip .count { + font-size: 0.75em; + margin-left: 2px; + opacity: 0.7; +} + +.alphabet-bar-section { + margin: 6px 0; + padding: 0 2px; + position: relative; +} + +.alphabet-bar-title { + font-size: 0.75em; + color: var(--text-color); + opacity: 0.7; + margin-right: 8px; + white-space: nowrap; +} + +@media (max-width: 768px) { + .alphabet-bar { + padding: 3px; + gap: 3px; + } + + .letter-chip { + padding: 3px 5px; + min-width: 16px; + font-size: 0.75em; + } +} + +/* Keyframe animations for the active letter */ +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +.letter-chip.active { + animation: pulse 1s ease-in-out 1; +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 4e2446c2..08cb7a0d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -21,6 +21,7 @@ @import 'components/filter-indicator.css'; @import 'components/initialization.css'; @import 'components/progress-panel.css'; +@import 'components/alphabet-bar.css'; /* Add alphabet bar component */ .initialization-notice { display: flex; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 45898601..60bc4b8a 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -49,6 +49,11 @@ export async function loadMoreModels(options = {}) { 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) { diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js new file mode 100644 index 00000000..69ee9369 --- /dev/null +++ b/static/js/components/alphabet/AlphabetBar.js @@ -0,0 +1,222 @@ +// AlphabetBar.js - Component for alphabet filtering +import { getCurrentPageState, setCurrentPageType } from '../../state/index.js'; +import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js'; +import { resetAndReload } from '../../api/loraApi.js'; + +/** + * AlphabetBar class - Handles the alphabet filtering UI and interactions + */ +export class AlphabetBar { + constructor(pageType = 'loras') { + // Store the page type + this.pageType = pageType; + + // Get the current page state + this.pageState = getCurrentPageState(); + + // Initialize letter counts + this.letterCounts = {}; + + // Initialize the component + this.initializeComponent(); + } + + /** + * Initialize the alphabet bar component + */ + async initializeComponent() { + // Get letter counts from API + await this.fetchLetterCounts(); + + // Initialize event listeners + this.initEventListeners(); + + // Restore the active letter filter from storage if available + this.restoreActiveLetterFilter(); + } + + /** + * Fetch letter counts from the API + */ + async fetchLetterCounts() { + try { + const response = await fetch('/api/loras/letter-counts'); + + if (!response.ok) { + throw new Error(`Failed to fetch letter counts: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.letter_counts) { + this.letterCounts = data.letter_counts; + + // Update the count display in the UI + this.updateLetterCountsDisplay(); + } + } catch (error) { + console.error('Error fetching letter counts:', error); + } + } + + /** + * Update the letter counts display in the UI + */ + updateLetterCountsDisplay() { + const letterChips = document.querySelectorAll('.letter-chip'); + + letterChips.forEach(chip => { + const letter = chip.dataset.letter; + const countSpan = chip.querySelector('.count'); + const count = this.letterCounts[letter] || 0; + + // Update the count display + if (countSpan) { + if (count > 0) { + countSpan.textContent = ` (${count})`; + chip.title = `${letter}: ${count} LoRAs`; + chip.classList.remove('disabled'); + } else { + countSpan.textContent = ''; + chip.title = `${letter}: No LoRAs`; + chip.classList.add('disabled'); + } + } + }); + } + + /** + * Initialize event listeners for the alphabet bar + */ + initEventListeners() { + const alphabetBar = document.querySelector('.alphabet-bar'); + + if (alphabetBar) { + // Use event delegation for letter chips + alphabetBar.addEventListener('click', (e) => { + const letterChip = e.target.closest('.letter-chip'); + + if (letterChip && !letterChip.classList.contains('disabled')) { + this.handleLetterClick(letterChip); + } + }); + + // Add keyboard shortcut listeners + document.addEventListener('keydown', (e) => { + // Alt + letter shortcuts + if (e.altKey && !e.ctrlKey && !e.metaKey) { + const key = e.key.toUpperCase(); + + // Check if it's a letter A-Z + if (/^[A-Z]$/.test(key)) { + const letterChip = document.querySelector(`.letter-chip[data-letter="${key}"]`); + + if (letterChip && !letterChip.classList.contains('disabled')) { + this.handleLetterClick(letterChip); + e.preventDefault(); + } + } + // Special cases for non-letter filters + else if (e.key === '0' || e.key === ')') { + // Alt+0 for numbers (#) + const letterChip = document.querySelector('.letter-chip[data-letter="#"]'); + + if (letterChip && !letterChip.classList.contains('disabled')) { + this.handleLetterClick(letterChip); + e.preventDefault(); + } + } else if (e.key === '2' || e.key === '@') { + // Alt+@ for special characters + const letterChip = document.querySelector('.letter-chip[data-letter="@"]'); + + if (letterChip && !letterChip.classList.contains('disabled')) { + this.handleLetterClick(letterChip); + e.preventDefault(); + } + } else if (e.key === 'c' || e.key === 'C') { + // Alt+C for CJK characters + const letterChip = document.querySelector('.letter-chip[data-letter="漢"]'); + + if (letterChip && !letterChip.classList.contains('disabled')) { + this.handleLetterClick(letterChip); + e.preventDefault(); + } + } + } + }); + } + } + + /** + * Handle letter chip click + * @param {HTMLElement} letterChip - The letter chip that was clicked + */ + handleLetterClick(letterChip) { + const letter = letterChip.dataset.letter; + const wasActive = letterChip.classList.contains('active'); + + // Remove active class from all letter chips + document.querySelectorAll('.letter-chip').forEach(chip => { + chip.classList.remove('active'); + }); + + if (!wasActive) { + // Set the new active letter + letterChip.classList.add('active'); + this.pageState.activeLetterFilter = letter; + + // Save to storage + setStorageItem(`${this.pageType}_activeLetterFilter`, letter); + } else { + // Clear the active letter filter + this.pageState.activeLetterFilter = null; + + // Remove from storage + setStorageItem(`${this.pageType}_activeLetterFilter`, null); + } + + // Trigger a reload with the new filter + resetAndReload(true); + } + + /** + * Restore the active letter filter from storage + */ + restoreActiveLetterFilter() { + const activeLetterFilter = getStorageItem(`${this.pageType}_activeLetterFilter`); + + if (activeLetterFilter) { + const letterChip = document.querySelector(`.letter-chip[data-letter="${activeLetterFilter}"]`); + + if (letterChip && !letterChip.classList.contains('disabled')) { + letterChip.classList.add('active'); + this.pageState.activeLetterFilter = activeLetterFilter; + } + } + } + + /** + * Clear the active letter filter + */ + clearActiveLetterFilter() { + // Remove active class from all letter chips + document.querySelectorAll('.letter-chip').forEach(chip => { + chip.classList.remove('active'); + }); + + // Clear the active letter filter + this.pageState.activeLetterFilter = null; + + // Remove from storage + setStorageItem(`${this.pageType}_activeLetterFilter`, null); + } + + /** + * Update letter counts with new data + * @param {Object} newCounts - New letter count data + */ + updateCounts(newCounts) { + this.letterCounts = { ...newCounts }; + this.updateLetterCountsDisplay(); + } +} \ No newline at end of file diff --git a/static/js/components/alphabet/index.js b/static/js/components/alphabet/index.js new file mode 100644 index 00000000..498a367a --- /dev/null +++ b/static/js/components/alphabet/index.js @@ -0,0 +1,14 @@ +// Alphabet component index file +import { AlphabetBar } from './AlphabetBar.js'; + +// Export the class +export { AlphabetBar }; + +/** + * Factory function to create the appropriate alphabet bar + * @param {string} pageType - The type of page ('loras' or 'checkpoints') + * @returns {AlphabetBar} - The alphabet bar instance + */ +export function createAlphabetBar(pageType) { + return new AlphabetBar(pageType); +} \ No newline at end of file diff --git a/static/js/components/controls/LorasControls.js b/static/js/components/controls/LorasControls.js index 521a1cee..dd021d5b 100644 --- a/static/js/components/controls/LorasControls.js +++ b/static/js/components/controls/LorasControls.js @@ -2,6 +2,7 @@ import { PageControls } from './PageControls.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; +import { createAlphabetBar } from '../alphabet/index.js'; /** * LorasControls class - Extends PageControls for LoRA-specific functionality @@ -16,6 +17,9 @@ export class LorasControls extends PageControls { // Check for custom filters (e.g., from recipe navigation) this.checkCustomFilters(); + + // Initialize alphabet bar component + this.initAlphabetBar(); } /** @@ -142,4 +146,27 @@ export class LorasControls extends PageControls { _truncateText(text, maxLength) { return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; } + + /** + * Initialize the alphabet bar component + */ + initAlphabetBar() { + // Create the alphabet bar component + this.alphabetBar = createAlphabetBar('loras'); + + // Expose the alphabet bar to the global scope for debugging + window.alphabetBar = this.alphabetBar; + } + + /** + * Override resetAndReload to update letter counts + */ + async resetAndReload(updateFolders = false) { + await super.resetAndReload(updateFolders); + + // Update letter counts after reload if alphabet bar exists + if (this.alphabetBar) { + this.alphabetBar.fetchLetterCounts(); + } + } } \ No newline at end of file diff --git a/static/js/state/index.js b/static/js/state/index.js index 23b41226..bbfa3ea9 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -27,6 +27,7 @@ export const state = { hasMore: true, sortBy: 'name', activeFolder: null, + activeLetterFilter: null, // New property for letter filtering previewVersions: loraPreviewVersions, searchManager: null, searchOptions: { diff --git a/templates/components/alphabet_bar.html b/templates/components/alphabet_bar.html new file mode 100644 index 00000000..9ef5e5fd --- /dev/null +++ b/templates/components/alphabet_bar.html @@ -0,0 +1,19 @@ +
+
+ Filter by: +
+ # +
+ {% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %} +
+ {{ letter }} +
+ {% endfor %} +
+ @ +
+
+ 漢 +
+
+
\ No newline at end of file diff --git a/templates/loras.html b/templates/loras.html index 83a8411c..00f1ee74 100644 --- a/templates/loras.html +++ b/templates/loras.html @@ -19,6 +19,7 @@ {% block content %} {% include 'components/controls.html' %} + {% include 'components/alphabet_bar.html' %}