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" /> + diff --git a/src/pages/blog/html-intro.md b/src/pages/blog/html-intro.md index a198c1d..0cc0f89 100644 --- a/src/pages/blog/html-intro.md +++ b/src/pages/blog/html-intro.md @@ -2,7 +2,7 @@ layout: ../../layouts/BlogLayout.astro 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 -tags: ["code", "html"] +tags: ["code", "htmlf"] time: 4 featured: true timestamp: 2024-12-18T02:39:03+00:00 diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 139c20a..7812018 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -3,8 +3,10 @@ import { GLOBAL } from "../../lib/variables"; import Layout from "../../layouts/Layout.astro"; import ArticleSnippet from "../../components/ArticleSnippet.astro"; import Section from "../../components/common/Section.astro"; +import SearchBar from "../../components/common/SearchBar.astro"; import { articles } from "../../lib/list"; 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)); @@ -35,32 +37,56 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte + + +
-
-

{GLOBAL.articlesName}

+
+ +
+
+ +
+
+ +
+

{GLOBAL.articlesName}

+
+ + +
+

No Articles Found

-
- {Object.entries(tagCounts).map(([tag, count]) => ( - - {tag}: {count} - - ))} +
    + { + articles.map((article) => { + const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : ''; + + return ( +
  • + +
  • + ); + }) + } +
-
    - { - articles.map((project) => ( -
  • - -
  • - )) - } -
diff --git a/src/pages/homelab/index.astro b/src/pages/homelab/index.astro index b098b38..28fc7b3 100644 --- a/src/pages/homelab/index.astro +++ b/src/pages/homelab/index.astro @@ -5,6 +5,7 @@ import Section from "../../components/common/Section.astro"; import SearchBar from "../../components/common/SearchBar.astro"; import CategorySection from "../../components/homelab/CategorySection.astro"; import { services } from "./services.ts"; +import { initializeSearch } from "../../components/common/searchUtils.js"; --- @@ -24,103 +25,10 @@ import { services } from "./services.ts"; + +
-
+
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index 1e188cc..9466b5f 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -2,8 +2,10 @@ import { projects } from "../../lib/list"; import Section from "../../components/common/Section.astro"; import ProjectSnippet from "../../components/ProjectSnippet.astro"; +import SearchBar from "../../components/common/SearchBar.astro"; import Layout from "../../layouts/Layout.astro"; import { GLOBAL } from "../../lib/variables"; +import { initializeSearch } from "../../components/common/searchUtils.js"; --- @@ -31,25 +33,61 @@ import { GLOBAL } from "../../lib/variables"; + + +
-
-

{GLOBAL.projectsName}

+
+ +
+
+ +
+
+ +
+

{GLOBAL.projectsName}

+
+ + +
+

No Projects Found

+
+ +
    + { + projects.map((project) => { + const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : ''; + const githubUrl = project.githubUrl || ''; + const liveUrl = project.liveUrl || ''; + + return ( +
  • + +
  • + ); + }) + } +
-
    - { - projects.map((project) => ( -
  • - -
  • - )) - } -
diff --git a/src/scripts/search.js b/src/scripts/search.js new file mode 100644 index 0000000..5cc47a5 --- /dev/null +++ b/src/scripts/search.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 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' + }); + }); +});