diff --git a/src/components/SearchScript.astro b/src/components/SearchScript.astro
new file mode 100644
index 0000000..8e363bf
--- /dev/null
+++ b/src/components/SearchScript.astro
@@ -0,0 +1,265 @@
+---
+---
+
+
diff --git a/src/components/common/SearchBar.astro b/src/components/common/SearchBar.astro
index db1ffea..7524b87 100644
--- a/src/components/common/SearchBar.astro
+++ b/src/components/common/SearchBar.astro
@@ -34,7 +34,7 @@ const {
diff --git a/src/components/common/search-client.js b/src/components/common/search-client.js
new file mode 100644
index 0000000..8ac46b6
--- /dev/null
+++ b/src/components/common/search-client.js
@@ -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'
+ });
+ });
+});
diff --git a/src/components/common/searchUtils.js b/src/components/common/searchUtils.js
new file mode 100644
index 0000000..ccbddec
--- /dev/null
+++ b/src/components/common/searchUtils.js
@@ -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;
+ }
+ }
+ }
+ };
+}
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index e93b614..e026ec7 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -1,6 +1,7 @@
---
import Footer from "../components/Footer.astro";
import Header from "../components/Header.astro";
+import SearchScript from "../components/SearchScript.astro";
import "../styles/global.css";
---
@@ -17,6 +18,7 @@ import "../styles/global.css";
rel="stylesheet"
/>
+
No Articles Found
No Projects Found
+