+ const config = { ...defaults, ...options };
+
+ return {
+ searchQuery: '',
+ hasResults: true,
+ visibleCount: 0,
+ loading: false, // Start with loading state false - the LoadingManager will control this
+
+ init() {
+ // Initialize the visible count
+ this.visibleCount = document.querySelectorAll(contentSelector).length;
+ this.setupWatchers();
+ this.setupKeyboardShortcuts();
+
+ // Handle theme changes
+ window.addEventListener('theme-changed', () => {
+ this.filterContent(this.searchQuery);
+ });
+ },
+
+ setupWatchers() {
+ this.$watch('searchQuery', (query) => {
+ // Filter content immediately - no artificial delay
+ 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', () => {
+ const baseSearch = initializeSearch('.app-card', {
+ 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'
+ });
+
+ // 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
+ displayMode: 'both', // both, image, or name
+ debounceTimeout: null, // For debouncing slider changes
+
+ init() {
+ baseSearch.init.call(this);
+
+ // Apply initial icon size, view mode, and display mode
+ this.applyIconSize();
+ this.applyViewMode();
+ this.applyDisplayMode();
+ },
+
+ // 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');
+ }
+ }
+ });
+ },
+
+ // Display mode methods
+ setDisplayMode(mode) {
+ this.displayMode = mode;
+ this.applyDisplayMode();
+ },
+
+ applyDisplayMode() {
+ const appList = document.getElementById('app-list');
+ if (!appList) return;
+
+ // Remove existing display mode classes
+ appList.classList.remove('display-both', 'display-image-only', 'display-name-only');
+
+ // Add the new display mode class
+ if (this.displayMode === 'image') {
+ appList.classList.add('display-image-only');
+ } else if (this.displayMode === 'name') {
+ appList.classList.add('display-name-only');
+ } else {
+ appList.classList.add('display-both');
+ }
+
+ // 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');
+ }
+ }
+ });
+ }
+ };
+ });
+
+ // 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'
+ });
+ });
+});
+
Well, this is awkward
404 - Page not found
It seems that this page does not exist. If you want to return to safety, click here to go home.
-
Well, this is awkward
404 - Page not found
It seems that this page does not exist. If you want to return to safety, click here to go home. -