2025-05-03 01:35:48 -07:00
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
<script is:inline>
|
|
|
|
/**
|
|
|
|
* Initialize search functionality for any content type
|
|
|
|
* @returns {Object} Alpine.js data object with search functionality
|
|
|
|
*/
|
|
|
|
function initializeSearch(contentSelector = '.searchable-item', options = {}) {
|
|
|
|
const defaults = {
|
|
|
|
nameAttribute: 'data-name',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
categoryAttribute: 'data-category',
|
|
|
|
additionalAttributes: [],
|
|
|
|
noResultsMessage: 'No results found',
|
|
|
|
allItemsMessage: 'Showing all items',
|
|
|
|
resultCountMessage: (count) => `Found ${count} items`,
|
|
|
|
itemLabel: 'items'
|
|
|
|
};
|
|
|
|
|
|
|
|
const config = { ...defaults, ...options };
|
|
|
|
|
|
|
|
return {
|
|
|
|
searchQuery: '',
|
|
|
|
hasResults: true,
|
|
|
|
visibleCount: 0,
|
2025-05-03 02:27:26 -07:00
|
|
|
loading: false, // Start with loading state false - the LoadingManager will control this
|
2025-05-03 01:35:48 -07:00
|
|
|
|
|
|
|
init() {
|
|
|
|
// Initialize the visible count
|
|
|
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
|
|
|
this.setupWatchers();
|
|
|
|
this.setupKeyboardShortcuts();
|
2025-05-03 02:27:26 -07:00
|
|
|
|
|
|
|
// Handle theme changes
|
|
|
|
window.addEventListener('theme-changed', () => {
|
|
|
|
this.filterContent(this.searchQuery);
|
|
|
|
});
|
2025-05-03 01:35:48 -07:00
|
|
|
},
|
|
|
|
|
|
|
|
setupWatchers() {
|
|
|
|
this.$watch('searchQuery', (query) => {
|
2025-05-03 02:27:26 -07:00
|
|
|
// Filter content immediately - no artificial delay
|
2025-05-03 01:35:48 -07:00
|
|
|
this.filterContent(query);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
setupKeyboardShortcuts() {
|
|
|
|
// Track the currently focused item index
|
|
|
|
this.focusedItemIndex = -1;
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
// '/' key focuses the search input
|
|
|
|
if (e.key === '/' && document.activeElement.id !== 'app-search') {
|
|
|
|
e.preventDefault();
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Escape key clears the search
|
|
|
|
if (e.key === 'Escape' && this.searchQuery !== '') {
|
|
|
|
this.searchQuery = '';
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
this.focusedItemIndex = -1;
|
|
|
|
this.clearItemFocus();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Arrow key navigation through results
|
|
|
|
if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
const visibleItems = this.getVisibleItems();
|
|
|
|
if (visibleItems.length === 0) return;
|
|
|
|
|
|
|
|
// Update focused item index
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1);
|
|
|
|
} else {
|
|
|
|
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clear previous focus
|
|
|
|
this.clearItemFocus();
|
|
|
|
|
|
|
|
// If we're back at -1, focus the search input
|
|
|
|
if (this.focusedItemIndex === -1) {
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Focus the new item
|
|
|
|
const itemToFocus = visibleItems[this.focusedItemIndex];
|
|
|
|
this.focusItem(itemToFocus);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Enter key selects the focused item
|
|
|
|
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
|
|
|
|
const visibleItems = this.getVisibleItems();
|
|
|
|
if (visibleItems.length === 0) return;
|
|
|
|
|
|
|
|
const selectedItem = visibleItems[this.focusedItemIndex];
|
|
|
|
const link = selectedItem.querySelector('a');
|
|
|
|
if (link) {
|
|
|
|
link.click();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
getVisibleItems() {
|
|
|
|
return Array.from(document.querySelectorAll(contentSelector))
|
|
|
|
.filter(item => item.style.display !== 'none');
|
|
|
|
},
|
|
|
|
|
|
|
|
clearItemFocus() {
|
|
|
|
// Remove focus styling from all items
|
|
|
|
document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => {
|
|
|
|
item.classList.remove('keyboard-focus');
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
focusItem(item) {
|
|
|
|
// Add focus styling
|
|
|
|
item.classList.add('keyboard-focus');
|
|
|
|
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
},
|
|
|
|
|
|
|
|
filterContent(query) {
|
|
|
|
query = query.toLowerCase();
|
|
|
|
let anyResults = false;
|
|
|
|
let visibleCount = 0;
|
|
|
|
|
|
|
|
// Process all content items
|
|
|
|
document.querySelectorAll(contentSelector).forEach((item) => {
|
|
|
|
// Get searchable attributes
|
|
|
|
const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase();
|
|
|
|
const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase();
|
|
|
|
const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase();
|
|
|
|
|
|
|
|
// Check additional attributes if specified
|
|
|
|
const additionalMatches = config.additionalAttributes.some(attr => {
|
|
|
|
const value = (item.getAttribute(attr) || '').toLowerCase();
|
|
|
|
return value.includes(query);
|
|
|
|
});
|
|
|
|
|
|
|
|
const isMatch = query === '' ||
|
|
|
|
name.includes(query) ||
|
|
|
|
tags.includes(query) ||
|
|
|
|
category.includes(query) ||
|
|
|
|
additionalMatches;
|
|
|
|
|
|
|
|
if (isMatch) {
|
|
|
|
item.style.display = '';
|
|
|
|
anyResults = true;
|
|
|
|
visibleCount++;
|
|
|
|
} else {
|
|
|
|
item.style.display = 'none';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Update category visibility for homelab page
|
|
|
|
this.updateCategoryVisibility(query);
|
|
|
|
|
|
|
|
// Update parent containers if needed
|
|
|
|
this.updateContainerVisibility(query);
|
|
|
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
|
|
|
},
|
|
|
|
|
|
|
|
updateCategoryVisibility(query) {
|
|
|
|
// Only proceed if we have category sections (homelab page)
|
|
|
|
const categorySections = document.querySelectorAll('.category-section');
|
|
|
|
if (categorySections.length === 0) return;
|
|
|
|
|
|
|
|
// For each category section, check if it has any visible app cards
|
|
|
|
categorySections.forEach((categorySection) => {
|
|
|
|
const categoryId = categorySection.getAttribute('data-category');
|
|
|
|
const appCards = categorySection.querySelectorAll('.app-card');
|
|
|
|
|
|
|
|
// Count visible app cards in this category
|
|
|
|
const visibleApps = Array.from(appCards).filter(card =>
|
|
|
|
card.style.display !== 'none'
|
|
|
|
).length;
|
|
|
|
|
|
|
|
// If no visible apps and we're searching, hide the category
|
|
|
|
if (query !== '' && visibleApps === 0) {
|
|
|
|
categorySection.style.display = 'none';
|
|
|
|
} else {
|
|
|
|
categorySection.style.display = '';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
updateContainerVisibility(query) {
|
|
|
|
// If there are container elements that should be hidden when empty
|
|
|
|
const containers = document.querySelectorAll('.content-container');
|
|
|
|
if (containers.length > 0) {
|
|
|
|
containers.forEach((container) => {
|
|
|
|
const hasVisibleItems = Array.from(
|
|
|
|
container.querySelectorAll(contentSelector)
|
|
|
|
).some((item) => item.style.display !== 'none');
|
|
|
|
|
|
|
|
if (query === '' || hasVisibleItems) {
|
|
|
|
container.style.display = '';
|
|
|
|
} else {
|
|
|
|
container.style.display = 'none';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
updateResultsStatus(query, anyResults, count) {
|
|
|
|
// Update results status
|
|
|
|
this.hasResults = query === '' || anyResults;
|
|
|
|
this.visibleCount = count;
|
|
|
|
|
|
|
|
// Update screen reader status
|
|
|
|
const statusEl = document.getElementById('search-status');
|
|
|
|
if (statusEl) {
|
|
|
|
if (query === '') {
|
|
|
|
statusEl.textContent = config.allItemsMessage;
|
|
|
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
|
|
|
} else if (this.hasResults) {
|
|
|
|
statusEl.textContent = config.resultCountMessage(count);
|
|
|
|
} else {
|
|
|
|
statusEl.textContent = config.noResultsMessage;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Register Alpine.js data components when Alpine is loaded
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
|
|
// Homelab search
|
|
|
|
window.Alpine.data('searchServices', () => {
|
2025-05-03 13:19:10 -07:00
|
|
|
const baseSearch = initializeSearch('.app-card', {
|
2025-05-03 01:35:48 -07:00
|
|
|
nameAttribute: 'data-app-name',
|
|
|
|
tagsAttribute: 'data-app-tags',
|
|
|
|
categoryAttribute: 'data-app-category',
|
|
|
|
noResultsMessage: 'No services found',
|
|
|
|
allItemsMessage: 'Showing all services',
|
|
|
|
resultCountMessage: (count) => `Found ${count} services`,
|
|
|
|
itemLabel: 'services'
|
|
|
|
});
|
2025-05-03 13:19:10 -07:00
|
|
|
|
|
|
|
// Add icon size slider functionality
|
|
|
|
return {
|
|
|
|
...baseSearch,
|
|
|
|
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
|
|
|
|
iconSize: 'medium', // small, medium, large
|
|
|
|
viewMode: 'grid', // grid or list
|
|
|
|
debounceTimeout: null, // For debouncing slider changes
|
|
|
|
|
|
|
|
init() {
|
|
|
|
baseSearch.init.call(this);
|
|
|
|
|
|
|
|
// Apply initial icon size and view mode
|
|
|
|
this.applyIconSize();
|
|
|
|
this.applyViewMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
// Icon size methods
|
|
|
|
setIconSize(size) {
|
|
|
|
if (typeof size === 'string') {
|
|
|
|
// Handle legacy string values (small, medium, large)
|
|
|
|
this.iconSize = size;
|
|
|
|
this.iconSizeValue = size === 'small' ? 1 : size === 'medium' ? 2 : 3;
|
|
|
|
} else {
|
|
|
|
// Handle slider numeric values
|
|
|
|
this.iconSizeValue = parseFloat(size);
|
|
|
|
|
|
|
|
// Map slider value to size name
|
|
|
|
if (this.iconSizeValue <= 1.33) {
|
|
|
|
this.iconSize = 'small';
|
|
|
|
} else if (this.iconSizeValue <= 2.33) {
|
|
|
|
this.iconSize = 'medium';
|
|
|
|
} else {
|
|
|
|
this.iconSize = 'large';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.applyIconSize();
|
|
|
|
},
|
|
|
|
|
|
|
|
// Handle slider input with debounce
|
|
|
|
handleSliderChange(event) {
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
|
|
// Clear any existing timeout
|
|
|
|
if (this.debounceTimeout) {
|
|
|
|
clearTimeout(this.debounceTimeout);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set a new timeout
|
|
|
|
this.debounceTimeout = setTimeout(() => {
|
|
|
|
this.setIconSize(value);
|
|
|
|
}, 50); // 50ms debounce
|
|
|
|
},
|
|
|
|
|
|
|
|
applyIconSize() {
|
|
|
|
const appList = document.getElementById('app-list');
|
|
|
|
if (!appList) return;
|
|
|
|
|
|
|
|
// Remove existing size classes
|
|
|
|
appList.classList.remove('icon-size-small', 'icon-size-medium', 'icon-size-large');
|
|
|
|
|
|
|
|
// Add the new size class
|
|
|
|
appList.classList.add(`icon-size-${this.iconSize}`);
|
|
|
|
|
|
|
|
// Apply custom CSS variable for fine-grained control
|
|
|
|
appList.style.setProperty('--icon-scale', this.iconSizeValue);
|
|
|
|
},
|
|
|
|
|
|
|
|
// View mode methods
|
|
|
|
toggleViewMode() {
|
|
|
|
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
|
|
|
|
this.applyViewMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
setViewMode(mode) {
|
|
|
|
this.viewMode = mode;
|
|
|
|
this.applyViewMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
applyViewMode() {
|
|
|
|
const appList = document.getElementById('app-list');
|
|
|
|
if (!appList) return;
|
|
|
|
|
|
|
|
// Remove existing view mode classes
|
|
|
|
appList.classList.remove('view-mode-grid', 'view-mode-list');
|
|
|
|
|
|
|
|
// Add the new view mode class
|
|
|
|
appList.classList.add(`view-mode-${this.viewMode}`);
|
|
|
|
|
|
|
|
// Update all category sections
|
|
|
|
document.querySelectorAll('.category-section').forEach(section => {
|
|
|
|
const gridContainer = section.querySelector('.grid');
|
|
|
|
if (gridContainer) {
|
|
|
|
// Update grid classes based on view mode
|
|
|
|
if (this.viewMode === 'grid') {
|
|
|
|
gridContainer.classList.remove('grid-cols-1');
|
|
|
|
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
} else {
|
|
|
|
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
gridContainer.classList.add('grid-cols-1');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
2025-05-03 01:35:48 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
// Blog search
|
|
|
|
window.Alpine.data('searchArticles', () => {
|
|
|
|
return initializeSearch('.article-item', {
|
|
|
|
nameAttribute: 'data-title',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
additionalAttributes: ['data-description'],
|
|
|
|
noResultsMessage: 'No articles found',
|
|
|
|
allItemsMessage: 'Showing all articles',
|
|
|
|
resultCountMessage: (count) => `Found ${count} articles`,
|
|
|
|
itemLabel: 'articles'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Projects search
|
|
|
|
window.Alpine.data('searchProjects', () => {
|
|
|
|
return initializeSearch('.project-item', {
|
|
|
|
nameAttribute: 'data-title',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
additionalAttributes: ['data-description', 'data-github', 'data-live'],
|
|
|
|
noResultsMessage: 'No projects found',
|
|
|
|
allItemsMessage: 'Showing all projects',
|
|
|
|
resultCountMessage: (count) => `Found ${count} projects`,
|
|
|
|
itemLabel: 'projects'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
</script>
|