Compare commits
2 Commits
1b3788d587
...
6e392cb8bf
Author | SHA1 | Date | |
---|---|---|---|
6e392cb8bf | |||
35fc2fd67b |
265
src/components/SearchScript.astro
Normal file
265
src/components/SearchScript.astro
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<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,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize the visible count
|
||||||
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
|
this.setupWatchers();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWatchers() {
|
||||||
|
this.$watch('searchQuery', (query) => {
|
||||||
|
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', () => {
|
||||||
|
return 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>
|
@ -23,33 +23,24 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
const theme = (() => {
|
// Theme toggle functionality - works with the flash prevention script in Layout
|
||||||
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
|
|
||||||
return localStorage.getItem("theme") ?? "light";
|
|
||||||
}
|
|
||||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
||||||
return "dark";
|
|
||||||
}
|
|
||||||
return "light";
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (theme === "light") {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem("theme", theme);
|
|
||||||
|
|
||||||
const handleToggleClick = () => {
|
const handleToggleClick = () => {
|
||||||
const element = document.documentElement;
|
const element = document.documentElement;
|
||||||
element.classList.toggle("dark");
|
element.classList.toggle("dark");
|
||||||
|
|
||||||
const isDark = element.classList.contains("dark");
|
const isDark = element.classList.contains("dark");
|
||||||
localStorage.setItem("theme", isDark ? "dark" : "light");
|
localStorage.setItem("theme", isDark ? "dark" : "light");
|
||||||
|
|
||||||
|
// Dispatch a custom event that other components can listen for
|
||||||
|
window.dispatchEvent(new CustomEvent('theme-changed', {
|
||||||
|
detail: { theme: isDark ? 'dark' : 'light' }
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
document
|
// Add event listener when the DOM is ready
|
||||||
.getElementById("themeToggle")
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
?.addEventListener("click", handleToggleClick);
|
document
|
||||||
|
.getElementById("themeToggle")
|
||||||
|
?.addEventListener("click", handleToggleClick);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -70,7 +70,7 @@ const {
|
|||||||
id="visible-status"
|
id="visible-status"
|
||||||
class="text-xs zag-text"
|
class="text-xs zag-text"
|
||||||
x-show="searchQuery !== ''"
|
x-show="searchQuery !== ''"
|
||||||
x-text="hasResults ? 'Found ' + visibleCount + ' services' : 'No services found'"
|
x-text="hasResults ? 'Found ' + visibleCount + ' ' + (visibleCount === 1 ? 'item' : 'items') : 'No results found'"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
176
src/components/common/search-client.js
Normal file
176
src/components/common/search-client.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Client-side search functionality for filtering content across the site
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize search functionality for any content type
|
||||||
|
* @param {string} contentSelector - CSS selector for the items to filter
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @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,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize the visible count
|
||||||
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
|
this.setupWatchers();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWatchers() {
|
||||||
|
this.$watch('searchQuery', (query) => {
|
||||||
|
this.filterContent(query);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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 parent containers if needed
|
||||||
|
this.updateContainerVisibility(query);
|
||||||
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
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
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Homelab search
|
||||||
|
window.Alpine.data('searchServices', () => {
|
||||||
|
return 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
132
src/components/common/searchUtils.js
Normal file
132
src/components/common/searchUtils.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Generic search utility for filtering content across the site
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize search functionality for any content type
|
||||||
|
* @returns {Object} Alpine.js data object with search functionality
|
||||||
|
*/
|
||||||
|
export 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,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize the visible count
|
||||||
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
|
this.setupWatchers();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWatchers() {
|
||||||
|
this.$watch('searchQuery', (query) => {
|
||||||
|
this.filterContent(query);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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 parent containers if needed
|
||||||
|
this.updateContainerVisibility(query);
|
||||||
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -1,12 +1,42 @@
|
|||||||
---
|
---
|
||||||
import Footer from "../components/Footer.astro";
|
import Footer from "../components/Footer.astro";
|
||||||
import Header from "../components/Header.astro";
|
import Header from "../components/Header.astro";
|
||||||
|
import SearchScript from "../components/SearchScript.astro";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<!-- Theme flash prevention script - must be first in head -->
|
||||||
|
<script is:inline>
|
||||||
|
// Immediately apply the saved theme to prevent flash
|
||||||
|
(function() {
|
||||||
|
function getThemePreference() {
|
||||||
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||||
|
return localStorage.getItem('theme');
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = getThemePreference();
|
||||||
|
|
||||||
|
// Apply theme immediately to prevent flash
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the theme in localStorage for future visits
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a class to indicate JS is loaded and theme is applied
|
||||||
|
document.documentElement.classList.add('theme-loaded');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/pixel_avatar.png" />
|
<link rel="icon" type="image/svg+xml" href="/pixel_avatar.png" />
|
||||||
@ -17,6 +47,7 @@ import "../styles/global.css";
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||||
|
<SearchScript />
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="zag-bg zag-text zag-transition font-mono">
|
<body class="zag-bg zag-text zag-transition font-mono">
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
layout: ../../layouts/BlogLayout.astro
|
layout: ../../layouts/BlogLayout.astro
|
||||||
title: No, We Have Netflix at Home
|
title: No, We Have Netflix at Home
|
||||||
description: How my exasperation at paying for an ever growing number of streaming services led to a deep obsession
|
description: How my exasperation at paying for an ever growing number of streaming services led to a deep obsession
|
||||||
tags: ["code", "html"]
|
tags: ["code", "htmlf"]
|
||||||
time: 4
|
time: 4
|
||||||
featured: true
|
featured: true
|
||||||
timestamp: 2024-12-18T02:39:03+00:00
|
timestamp: 2024-12-18T02:39:03+00:00
|
||||||
|
@ -3,8 +3,10 @@ import { GLOBAL } from "../../lib/variables";
|
|||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import { articles } from "../../lib/list";
|
import { articles } from "../../lib/list";
|
||||||
import { countTags } from "../../lib/utils";
|
import { countTags } from "../../lib/utils";
|
||||||
|
import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||||
|
|
||||||
const tagCounts = countTags(articles.map((article) => article.tags).flat().filter((tag): tag is string => tag !== undefined));
|
const tagCounts = countTags(articles.map((article) => article.tags).flat().filter((tag): tag is string => tag !== undefined));
|
||||||
|
|
||||||
@ -35,32 +37,56 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
|
|||||||
<meta name="language" content="English" />
|
<meta name="language" content="English" />
|
||||||
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
|
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div class="flex items-center gap-4 pt-8 pb-4">
|
<div x-data="searchArticles" x-init="init()">
|
||||||
<h1 class="font-display text-3xl sm:text-4xl leading-loose">{GLOBAL.articlesName}</h1>
|
<!-- Search container - positioned at the top -->
|
||||||
|
<div class="mb-4 pt-0">
|
||||||
|
<div class="w-full">
|
||||||
|
<SearchBar
|
||||||
|
placeholder="Search articles..."
|
||||||
|
ariaLabel="Search articles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 pt-8 pb-4">
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl leading-loose">{GLOBAL.articlesName}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
x-show="searchQuery !== '' && !hasResults"
|
||||||
|
x-transition
|
||||||
|
class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-xl font-semibold zag-text">No Articles Found</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 pb-16">
|
<ul id="article-list">
|
||||||
{Object.entries(tagCounts).map(([tag, count]) => (
|
{
|
||||||
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
|
articles.map((article) => {
|
||||||
{tag}: {count}
|
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';
|
||||||
</span>
|
|
||||||
))}
|
return (
|
||||||
|
<li class="article-item"
|
||||||
|
data-title={article.title.toLowerCase()}
|
||||||
|
data-description={article.description.toLowerCase()}
|
||||||
|
data-tags={articleTags}>
|
||||||
|
<ArticleSnippet
|
||||||
|
title={article.title}
|
||||||
|
description={article.description}
|
||||||
|
duration={`${article.time} min`}
|
||||||
|
url={article.filename}
|
||||||
|
timestamp={article.timestamp}
|
||||||
|
tags={article.tags}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
articles.map((project) => (
|
|
||||||
<li>
|
|
||||||
<ArticleSnippet
|
|
||||||
title={project.title}
|
|
||||||
description={project.description}
|
|
||||||
duration={`${project.time} min`}
|
|
||||||
url={project.filename}
|
|
||||||
timestamp={project.timestamp}
|
|
||||||
tags={project.tags}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</Section>
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -5,6 +5,7 @@ import Section from "../../components/common/Section.astro";
|
|||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import CategorySection from "../../components/homelab/CategorySection.astro";
|
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||||
import { services } from "./services.ts";
|
import { services } from "./services.ts";
|
||||||
|
import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -24,103 +25,10 @@ import { services } from "./services.ts";
|
|||||||
<link rel="canonical" href={GLOBAL.rootUrl} />
|
<link rel="canonical" href={GLOBAL.rootUrl} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div x-data="{
|
<div x-data="searchServices" x-init="init()">
|
||||||
searchQuery: '',
|
|
||||||
hasResults: true,
|
|
||||||
visibleCount: 0,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Initialize the visible count
|
|
||||||
this.visibleCount = document.querySelectorAll('.app-card').length;
|
|
||||||
this.setupWatchers();
|
|
||||||
this.setupKeyboardShortcuts();
|
|
||||||
},
|
|
||||||
|
|
||||||
setupWatchers() {
|
|
||||||
this.$watch('searchQuery', (query) => {
|
|
||||||
this.filterServices(query);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setupKeyboardShortcuts() {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
filterServices(query) {
|
|
||||||
query = query.toLowerCase();
|
|
||||||
let anyResults = false;
|
|
||||||
let visibleCount = 0;
|
|
||||||
|
|
||||||
// Process all service cards
|
|
||||||
document.querySelectorAll('.app-card').forEach((card) => {
|
|
||||||
const serviceName = card.getAttribute('data-app-name') || '';
|
|
||||||
const serviceTags = card.getAttribute('data-app-tags') || '';
|
|
||||||
const serviceCategory = card.getAttribute('data-app-category') || '';
|
|
||||||
|
|
||||||
const isMatch = query === '' ||
|
|
||||||
serviceName.includes(query) ||
|
|
||||||
serviceTags.includes(query) ||
|
|
||||||
serviceCategory.includes(query);
|
|
||||||
|
|
||||||
if (isMatch) {
|
|
||||||
card.style.display = '';
|
|
||||||
anyResults = true;
|
|
||||||
visibleCount++;
|
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateCategoryVisibility(query);
|
|
||||||
this.updateResultsStatus(query, anyResults, visibleCount);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateCategoryVisibility(query) {
|
|
||||||
document.querySelectorAll('.category-section').forEach((category) => {
|
|
||||||
const hasVisibleApps = Array.from(
|
|
||||||
category.querySelectorAll('.app-card')
|
|
||||||
).some((card) => card.style.display !== 'none');
|
|
||||||
|
|
||||||
if (query === '' || hasVisibleApps) {
|
|
||||||
category.style.display = '';
|
|
||||||
} else {
|
|
||||||
category.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 = 'Showing all services';
|
|
||||||
this.visibleCount = document.querySelectorAll('.app-card').length;
|
|
||||||
} else if (this.hasResults) {
|
|
||||||
statusEl.textContent = 'Found ' + count + ' services matching ' + query;
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = 'No services found matching ' + query;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}" x-init="init()">
|
|
||||||
<!-- Search container - positioned at the top -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
import { projects } from "../../lib/list";
|
import { projects } from "../../lib/list";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
||||||
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
|
import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -31,25 +33,61 @@ import { GLOBAL } from "../../lib/variables";
|
|||||||
<meta name="language" content="English" />
|
<meta name="language" content="English" />
|
||||||
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
|
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="py-4 my-8">
|
<Section class="py-4 my-8">
|
||||||
<div class="flex items-center gap-4 pt-8 pb-16">
|
<div x-data="searchProjects" x-init="init()">
|
||||||
<h1 class="font-display text-3xl sm:text-4xl leading-loose">{GLOBAL.projectsName}</h1>
|
<!-- Search container - positioned at the top -->
|
||||||
|
<div class="mb-4 pt-0">
|
||||||
|
<div class="w-full">
|
||||||
|
<SearchBar
|
||||||
|
placeholder="Search projects..."
|
||||||
|
ariaLabel="Search projects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 pt-8 pb-16">
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl leading-loose">{GLOBAL.projectsName}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
x-show="searchQuery !== '' && !hasResults"
|
||||||
|
x-transition
|
||||||
|
class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-xl font-semibold zag-text">No Projects Found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="project-list">
|
||||||
|
{
|
||||||
|
projects.map((project) => {
|
||||||
|
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';
|
||||||
|
const githubUrl = project.githubUrl || '';
|
||||||
|
const liveUrl = project.liveUrl || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="project-item"
|
||||||
|
data-title={project.title.toLowerCase()}
|
||||||
|
data-description={project.description.toLowerCase()}
|
||||||
|
data-tags={projectTags}
|
||||||
|
data-github={githubUrl.toLowerCase()}
|
||||||
|
data-live={liveUrl.toLowerCase()}>
|
||||||
|
<ProjectSnippet
|
||||||
|
title={project.title}
|
||||||
|
description={project.description}
|
||||||
|
url={project.filename}
|
||||||
|
githubUrl={project.githubUrl}
|
||||||
|
liveUrl={project.liveUrl}
|
||||||
|
tags={project.tags ?? []}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
projects.map((project) => (
|
|
||||||
<li>
|
|
||||||
<ProjectSnippet
|
|
||||||
title={project.title}
|
|
||||||
description={project.description}
|
|
||||||
url={project.filename}
|
|
||||||
githubUrl={project.githubUrl}
|
|
||||||
liveUrl={project.liveUrl}
|
|
||||||
tags={project.tags ?? []}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</Section>
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
176
src/scripts/search.js
Normal file
176
src/scripts/search.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Client-side search functionality for filtering content across the site
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize search functionality for any content type
|
||||||
|
* @param {string} contentSelector - CSS selector for the items to filter
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @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,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize the visible count
|
||||||
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
|
this.setupWatchers();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWatchers() {
|
||||||
|
this.$watch('searchQuery', (query) => {
|
||||||
|
this.filterContent(query);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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 parent containers if needed
|
||||||
|
this.updateContainerVisibility(query);
|
||||||
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
return 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -3,6 +3,16 @@
|
|||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/* Prevent theme flash by hiding content until theme is applied */
|
||||||
|
html:not(.theme-loaded) body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure smooth transitions between themes */
|
||||||
|
html.theme-loaded body {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Literata Variable";
|
font-family: "Literata Variable";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user