mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: implement alphabet filtering feature with letter counts and UI components v1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
89
static/css/components/alphabet-bar.css
Normal file
89
static/css/components/alphabet-bar.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
222
static/js/components/alphabet/AlphabetBar.js
Normal file
222
static/js/components/alphabet/AlphabetBar.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
14
static/js/components/alphabet/index.js
Normal file
14
static/js/components/alphabet/index.js
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
19
templates/components/alphabet_bar.html
Normal file
19
templates/components/alphabet_bar.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="alphabet-bar-section">
|
||||
<div class="alphabet-bar">
|
||||
<span class="alphabet-bar-title">Filter by:</span>
|
||||
<div class="letter-chip" data-letter="#" title="Numbers">
|
||||
#<span class="count"></span>
|
||||
</div>
|
||||
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
|
||||
<div class="letter-chip" data-letter="{{ letter }}" title="{{ letter }}">
|
||||
{{ letter }}<span class="count"></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="letter-chip" data-letter="@" title="Special characters">
|
||||
@<span class="count"></span>
|
||||
</div>
|
||||
<div class="letter-chip" data-letter="漢" title="CJK characters">
|
||||
漢<span class="count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% include 'components/controls.html' %}
|
||||
{% include 'components/alphabet_bar.html' %}
|
||||
<!-- Lora卡片容器 -->
|
||||
<div class="card-grid" id="loraGrid">
|
||||
<!-- Cards will be dynamically inserted here -->
|
||||
|
||||
Reference in New Issue
Block a user