Update Home Lab Search Abilities and Appearence
All checks were successful
Build and Deploy / build (push) Successful in 28s
All checks were successful
Build and Deploy / build (push) Successful in 28s
This commit is contained in:
parent
4409625260
commit
1b3788d587
76
src/components/common/SearchBar.astro
Normal file
76
src/components/common/SearchBar.astro
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
placeholder = "Search...",
|
||||
ariaLabel = "Search",
|
||||
className = "",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`search-container ${className}`}>
|
||||
<!-- Hidden live region for screen readers -->
|
||||
<div
|
||||
id="search-status"
|
||||
class="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
Showing all services
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label for="app-search" class="sr-only">{ariaLabel}</label>
|
||||
<!-- Search icon -->
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="app-search"
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder={placeholder}
|
||||
role="searchbox"
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby="search-status search-hint"
|
||||
aria-controls="app-list"
|
||||
class="w-full pl-8 pr-8 py-1 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg"
|
||||
/>
|
||||
<button
|
||||
x-show="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
aria-label="Clear search"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 zag-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip area - shows either help text or status text -->
|
||||
<div class="mt-1 h-5 text-center"> <!-- Fixed height to prevent layout shift -->
|
||||
<!-- Keyboard shortcut hint - shown when search is empty -->
|
||||
<div
|
||||
id="search-hint"
|
||||
class="text-xs zag-text"
|
||||
x-show="searchQuery === ''"
|
||||
>
|
||||
<kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text">/ </kbd> to search, <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text">Esc</kbd> to clear
|
||||
</div>
|
||||
|
||||
<!-- Status text - shown when user is typing -->
|
||||
<div
|
||||
id="visible-status"
|
||||
class="text-xs zag-text"
|
||||
x-show="searchQuery !== ''"
|
||||
x-text="hasResults ? 'Found ' + visibleCount + ' services' : 'No services found'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
73
src/components/homelab/CategorySection.astro
Normal file
73
src/components/homelab/CategorySection.astro
Normal file
@ -0,0 +1,73 @@
|
||||
---
|
||||
interface Props {
|
||||
category: string;
|
||||
apps: Array<{
|
||||
name: string;
|
||||
link: string;
|
||||
icon: string;
|
||||
alt: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const { category, apps } = Astro.props;
|
||||
import ServiceCard from "../common/ServiceCard.astro";
|
||||
|
||||
// Pre-compute values during server-side rendering
|
||||
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const categoryLower = category.toLowerCase();
|
||||
---
|
||||
|
||||
<div class="mb-8 category-section" data-category={categoryLower} x-data="{ open: true }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between"
|
||||
aria-expanded="true"
|
||||
:aria-expanded="open.toString()"
|
||||
aria-controls={categoryId}
|
||||
>
|
||||
{category}
|
||||
<svg
|
||||
:class="{ 'rotate-180': open }"
|
||||
class="w-5 h-5 transform transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
id={categoryId}
|
||||
>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{apps.length > 0 ? (
|
||||
apps.map(app => {
|
||||
const appName = app.name.toLowerCase();
|
||||
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
class="app-card transition-all duration-300"
|
||||
data-app-name={appName}
|
||||
data-app-tags={appTags}
|
||||
data-app-category={categoryLower}
|
||||
>
|
||||
<ServiceCard
|
||||
name={app.name}
|
||||
href={app.link}
|
||||
img={app.icon}
|
||||
alt={app.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p class="text-center col-span-full">Coming soon...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
100
src/components/homelab/searchUtils.js
Normal file
100
src/components/homelab/searchUtils.js
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Initialize search functionality for the homelab page
|
||||
* This function sets up the search filtering, keyboard shortcuts,
|
||||
* and status updates for the search feature
|
||||
*/
|
||||
function initializeSearch() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
hasResults: true,
|
||||
|
||||
init() {
|
||||
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, visibleCount) {
|
||||
// Update results status
|
||||
this.hasResults = query === '' || anyResults;
|
||||
|
||||
// Update screen reader status
|
||||
const statusEl = document.getElementById('search-status');
|
||||
if (statusEl) {
|
||||
if (query === '') {
|
||||
statusEl.textContent = 'Showing all services';
|
||||
} else if (this.hasResults) {
|
||||
statusEl.textContent = 'Found ' + visibleCount + ' services matching ' + query;
|
||||
} else {
|
||||
statusEl.textContent = 'No services found matching ' + query;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { initializeSearch };
|
@ -2,17 +2,13 @@
|
||||
import { GLOBAL } from "../../lib/variables";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Section from "../../components/common/Section.astro";
|
||||
import ServiceCard from "../../components/common/ServiceCard.astro";
|
||||
import SearchBar from "../../components/common/SearchBar.astro";
|
||||
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||
import { services } from "./services.ts";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Fragment slot="head">
|
||||
<script is:inline>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// No global store needed
|
||||
});
|
||||
</script>
|
||||
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
|
||||
<meta name="description" content={GLOBAL.longDescription} />
|
||||
<meta property="og:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} />
|
||||
@ -29,22 +25,57 @@ import { services } from "./services.ts";
|
||||
</Fragment>
|
||||
|
||||
<Section class="my-8">
|
||||
<div x-data="{ searchQuery: '', hasResults: true }" x-init="
|
||||
$watch('searchQuery', (query) => {
|
||||
<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 app cards
|
||||
document.querySelectorAll('.app-card').forEach(card => {
|
||||
const appName = card.getAttribute('data-app-name') || '';
|
||||
const appTags = card.getAttribute('data-app-tags') || '';
|
||||
const appCategory = card.getAttribute('data-app-category') || '';
|
||||
// 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') || '';
|
||||
|
||||
if (query === '' ||
|
||||
appName.includes(query) ||
|
||||
appTags.includes(query) ||
|
||||
appCategory.includes(query)) {
|
||||
const isMatch = query === '' ||
|
||||
serviceName.includes(query) ||
|
||||
serviceTags.includes(query) ||
|
||||
serviceCategory.includes(query);
|
||||
|
||||
if (isMatch) {
|
||||
card.style.display = '';
|
||||
anyResults = true;
|
||||
visibleCount++;
|
||||
@ -53,11 +84,15 @@ import { services } from "./services.ts";
|
||||
}
|
||||
});
|
||||
|
||||
// Check each category
|
||||
document.querySelectorAll('.category-section').forEach(category => {
|
||||
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');
|
||||
).some((card) => card.style.display !== 'none');
|
||||
|
||||
if (query === '' || hasVisibleApps) {
|
||||
category.style.display = '';
|
||||
@ -65,134 +100,57 @@ import { services } from "./services.ts";
|
||||
category.style.display = 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateResultsStatus(query, anyResults, count) {
|
||||
// Update results status
|
||||
hasResults = query === '' || anyResults;
|
||||
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 apps';
|
||||
} else if (hasResults) {
|
||||
statusEl.textContent = 'Found ' + visibleCount + ' apps matching ' + 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 apps found matching ' + query;
|
||||
statusEl.textContent = 'No services found matching ' + query;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add keyboard support for Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && searchQuery !== '') {
|
||||
searchQuery = '';
|
||||
document.getElementById('app-search').focus();
|
||||
}
|
||||
});
|
||||
">
|
||||
<div class="flex items-center gap-4 pt-8 pb-4">
|
||||
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<!-- Hidden live region for screen readers -->
|
||||
<div
|
||||
id="search-status"
|
||||
class="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
Showing all apps
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label for="app-search" class="sr-only">Search apps</label>
|
||||
<input
|
||||
id="app-search"
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder="Search apps..."
|
||||
role="searchbox"
|
||||
aria-label="Search apps"
|
||||
aria-describedby="search-status"
|
||||
aria-controls="app-list"
|
||||
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 zag-bg zag-text"
|
||||
}" x-init="init()">
|
||||
<!-- Search container - positioned at the top -->
|
||||
<div class="mb-4 pt-0">
|
||||
<div class="w-full">
|
||||
<SearchBar
|
||||
placeholder="Search services..."
|
||||
ariaLabel="Search services"
|
||||
/>
|
||||
<button
|
||||
x-show="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
aria-label="Clear search"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page heading - now below search -->
|
||||
<div class="flex items-center gap-4 pb-4 mt-2">
|
||||
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
|
||||
</div>
|
||||
|
||||
<!-- No results message -->
|
||||
<div
|
||||
x-show="searchQuery !== '' && !hasResults"
|
||||
x-transition
|
||||
class="text-center py-8 my-4 border-2 border-dashed border-gray-300 rounded-lg"
|
||||
class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"
|
||||
>
|
||||
<p class="text-xl font-semibold">No Results</p>
|
||||
<p class="text-xl font-semibold zag-text">No Results</p>
|
||||
</div>
|
||||
|
||||
<!-- Service categories -->
|
||||
<div id="app-list">
|
||||
{Object.entries(services).map(([category, apps], index) => (
|
||||
<div class="mb-8 category-section" data-category={category} x-data="{ open: true }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between"
|
||||
aria-expanded="true"
|
||||
:aria-expanded="open.toString()"
|
||||
:aria-controls="'category-' + '${category}'.toLowerCase().replace(/\\s+/g, '-')"
|
||||
>
|
||||
{category}
|
||||
<svg
|
||||
:class="{ 'rotate-180': open }"
|
||||
class="w-5 h-5 transform transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
:id="'category-' + '${category}'.toLowerCase().replace(/\\s+/g, '-')"
|
||||
>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{apps.length > 0 ? (
|
||||
apps.map(app => (
|
||||
<div
|
||||
class="app-card"
|
||||
data-app-name={app.name.toLowerCase()}
|
||||
data-app-tags={app.tags ? app.tags.join(' ').toLowerCase() : ''}
|
||||
data-app-category={category.toLowerCase()}
|
||||
>
|
||||
<ServiceCard
|
||||
name={app.name}
|
||||
href={app.link}
|
||||
img={app.icon}
|
||||
alt={app.name}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p class="text-center col-span-full">Coming soon...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(services).map(([category, apps]) => (
|
||||
<CategorySection category={category} apps={apps} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Section>
|
||||
</Layout>
|
||||
|
Loading…
x
Reference in New Issue
Block a user