Compare commits

..

No commits in common. "6e392cb8bf1081d5c28bca42ea3a5d4d320e6f84" and "1b3788d58763bcce40817bdb8c0227bb97af00af" have entirely different histories.

12 changed files with 162 additions and 915 deletions

View File

@ -1,265 +0,0 @@
---
---
<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>

View File

@ -23,24 +23,33 @@
</button>
<script is:inline>
// Theme toggle functionality - works with the flash prevention script in Layout
const theme = (() => {
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 element = document.documentElement;
element.classList.toggle("dark");
const isDark = element.classList.contains("dark");
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' }
}));
};
// Add event listener when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
document
.getElementById("themeToggle")
?.addEventListener("click", handleToggleClick);
});
document
.getElementById("themeToggle")
?.addEventListener("click", handleToggleClick);
</script>

View File

@ -34,7 +34,7 @@ const {
<input
id="app-search"
type="text"
x-model="searchQuery"
x-model="searchQuery"
placeholder={placeholder}
role="searchbox"
aria-label={ariaLabel}
@ -70,7 +70,7 @@ const {
id="visible-status"
class="text-xs zag-text"
x-show="searchQuery !== ''"
x-text="hasResults ? 'Found ' + visibleCount + ' ' + (visibleCount === 1 ? 'item' : 'items') : 'No results found'"
x-text="hasResults ? 'Found ' + visibleCount + ' services' : 'No services found'"
></div>
</div>
</div>

View File

@ -1,176 +0,0 @@
/**
* 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'
});
});
});

View File

@ -1,132 +0,0 @@
/**
* 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;
}
}
}
};
}

View File

@ -1,42 +1,12 @@
---
import Footer from "../components/Footer.astro";
import Header from "../components/Header.astro";
import SearchScript from "../components/SearchScript.astro";
import "../styles/global.css";
---
<!doctype html>
<html lang="en">
<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 name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/pixel_avatar.png" />
@ -47,7 +17,6 @@ import "../styles/global.css";
rel="stylesheet"
/>
<script src="https://unpkg.com/alpinejs" defer></script>
<SearchScript />
<slot name="head" />
</head>
<body class="zag-bg zag-text zag-transition font-mono">

View File

@ -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", "htmlf"]
tags: ["code", "html"]
time: 4
featured: true
timestamp: 2024-12-18T02:39:03+00:00

View File

@ -3,10 +3,8 @@ 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));
@ -37,56 +35,32 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
<meta name="language" content="English" />
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
</Fragment>
<!-- Search functionality is provided by search-client.js -->
<Section class="my-8">
<div x-data="searchArticles" x-init="init()">
<!-- 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 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>
<ul id="article-list">
{
articles.map((article) => {
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';
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 class="flex flex-wrap gap-2 pb-16">
{Object.entries(tagCounts).map(([tag, count]) => (
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
{tag}: {count}
</span>
))}
</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>
</Layout>

View File

@ -5,7 +5,6 @@ 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";
---
<Layout>
@ -25,10 +24,103 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<link rel="canonical" href={GLOBAL.rootUrl} />
</Fragment>
<!-- Search functionality is provided by search-client.js -->
<Section class="my-8">
<div x-data="searchServices" x-init="init()">
<div x-data="{
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 -->
<div class="mb-4 pt-0">
<div class="w-full">

View File

@ -2,10 +2,8 @@
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";
---
<Layout>
@ -33,61 +31,25 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<meta name="language" content="English" />
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
</Fragment>
<!-- Search functionality is provided by search-client.js -->
<Section class="py-4 my-8">
<div x-data="searchProjects" x-init="init()">
<!-- 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 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>
<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>
</Layout>

View File

@ -1,176 +0,0 @@
/**
* 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'
});
});
});

View File

@ -3,16 +3,6 @@
@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-family: "Literata Variable";
font-style: normal;