Add's the Ability to Search On Categories and Predefined Tags

This commit is contained in:
Justin Deal 2025-05-02 23:46:26 -07:00
parent dc75b39596
commit 4409625260
2 changed files with 126 additions and 37 deletions

View File

@ -29,51 +29,99 @@ import { services } from "./services.ts";
</Fragment> </Fragment>
<Section class="my-8"> <Section class="my-8">
<div x-data="{ searchQuery: '' }" x-init=" <div x-data="{ searchQuery: '', hasResults: true }" x-init="
$watch('searchQuery', (query) => { $watch('searchQuery', (query) => {
query = query.toLowerCase(); query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// First, process all app cards // Process all app cards
document.querySelectorAll('.app-card').forEach(card => { document.querySelectorAll('.app-card').forEach(card => {
const appName = card.getAttribute('data-app-name').toLowerCase(); const appName = card.getAttribute('data-app-name') || '';
if (query === '' || appName.includes(query)) { const appTags = card.getAttribute('data-app-tags') || '';
const appCategory = card.getAttribute('data-app-category') || '';
if (query === '' ||
appName.includes(query) ||
appTags.includes(query) ||
appCategory.includes(query)) {
card.style.display = ''; card.style.display = '';
anyResults = true;
visibleCount++;
} else { } else {
card.style.display = 'none'; card.style.display = 'none';
} }
}); });
// Then, check each category to see if it has any visible apps // Check each category
document.querySelectorAll('.category-section').forEach(category => { document.querySelectorAll('.category-section').forEach(category => {
const categoryName = category.getAttribute('data-category');
const hasVisibleApps = Array.from( const hasVisibleApps = Array.from(
category.querySelectorAll('.app-card') category.querySelectorAll('.app-card')
).some(card => card.style.display !== 'none'); ).some(card => card.style.display !== 'none');
// Show/hide the category based on whether it has visible apps
if (query === '' || hasVisibleApps) { if (query === '' || hasVisibleApps) {
category.style.display = ''; category.style.display = '';
} else { } else {
category.style.display = 'none'; category.style.display = 'none';
} }
}); });
})
// Update results status
hasResults = query === '' || anyResults;
// 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;
} else {
statusEl.textContent = 'No apps 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"> <div class="flex items-center gap-4 pt-8 pb-4">
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1> <h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
</div> </div>
<div class="mb-6"> <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"> <div class="relative">
<label for="app-search" class="sr-only">Search apps</label>
<input <input
id="app-search"
type="text" type="text"
x-model="searchQuery" x-model="searchQuery"
placeholder="Search apps..." 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" class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 zag-bg zag-text"
/> />
<button <button
x-show="searchQuery" x-show="searchQuery"
@click="searchQuery = ''" @click="searchQuery = ''"
aria-label="Clear search"
class="absolute right-3 top-1/2 transform -translate-y-1/2" 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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -82,13 +130,26 @@ import { services } from "./services.ts";
</button> </button>
</div> </div>
</div> </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"
>
<p class="text-xl font-semibold">No Results</p>
</div>
{Object.entries(services).map(([category, apps], index) => ( <div id="app-list">
<div class="mb-8 category-section" data-category={category} x-data="{ open: true }"> {Object.entries(services).map(([category, apps], index) => (
<button <div class="mb-8 category-section" data-category={category} x-data="{ open: true }">
@click="open = !open" <button
class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between" @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} {category}
<svg <svg
:class="{ 'rotate-180': open }" :class="{ 'rotate-180': open }"
@ -101,11 +162,20 @@ import { services } from "./services.ts";
</svg> </svg>
</button> </button>
<div x-show="open" x-transition> <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"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{apps.length > 0 ? ( {apps.length > 0 ? (
apps.map(app => ( apps.map(app => (
<div class="app-card" data-app-name={app.name.toLowerCase()}> <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 <ServiceCard
name={app.name} name={app.name}
href={app.link} href={app.link}
@ -118,9 +188,10 @@ import { services } from "./services.ts";
<p class="text-center col-span-full">Coming soon...</p> <p class="text-center col-span-full">Coming soon...</p>
)} )}
</div> </div>
</div>
</div> </div>
</div> ))}
))} </div>
</div> </div>
</Section> </Section>

View File

@ -12,43 +12,50 @@ export const services = {
name: "Silverbullet", name: "Silverbullet",
link: "https://notes.justin.deal", link: "https://notes.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/silverbullet.png", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/silverbullet.png",
alt: "Silverbullet" alt: "Silverbullet",
tags: ["notes", "markdown", "knowledge base"]
}, },
{ {
name: "Vikunja", name: "Vikunja",
link: "https://todo.justin.deal", link: "https://todo.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vikunja.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vikunja.svg",
alt: "Vikunja" alt: "Vikunja",
tags: ["todo", "tasks", "productivity"]
}, },
{ {
name: "Actual", name: "Actual",
link: "https://budget.justin.deal", link: "https://budget.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/actual-budget.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/actual-budget.svg",
alt: "Actual" alt: "Actual",
tags: ["finance", "budget", "money"]
}, },
{ {
name: "Searxng", name: "Searxng",
link: "https://search.justin.deal", link: "https://search.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/searxng.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/searxng.svg",
alt: "Searxng" alt: "Searxng",
tags: ["search", "privacy", "metasearch"]
}, },
{ {
name: "TeslaMate", name: "TeslaMate",
link: "https://tesla.justin.deal", link: "https://tesla.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/teslamate.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/teslamate.svg",
alt: "TeslaMate" alt: "TeslaMate",
tags: []
}, },
{ {
name: "BaiKal", name: "BaiKal",
link: "https://dav.justin.deal", link: "https://dav.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/baikal.png", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/baikal.png",
alt: "BaiKal" alt: "BaiKal",
tags: []
}, },
{ {
name: "Cryptpad", name: "Cryptpad",
link: "https://docs.justin.deal", link: "https://docs.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/cryptpad.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/cryptpad.svg",
alt: "Cryptpad" alt: "Cryptpad",
tags: []
} }
], ],
Development: [ Development: [
@ -56,19 +63,22 @@ export const services = {
name: "Gitea", name: "Gitea",
link: "https://code.justin.deal", link: "https://code.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
alt: "Gitea" alt: "Gitea",
tags: []
}, },
{ {
name: "OpenGist", name: "OpenGist",
link: "https://snippets.justin.deal", link: "https://snippets.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/opengist.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/opengist.svg",
alt: "OpenGist" alt: "OpenGist",
tags: []
}, },
{ {
name: "IT-Tools", name: "IT-Tools",
link: "https://tools.justin.deal", link: "https://tools.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/it-tools.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/it-tools.svg",
alt: "IT-Tools" alt: "IT-Tools",
tags: []
} }
], ],
Media: [ Media: [
@ -76,13 +86,15 @@ export const services = {
name: "Jellyfin", name: "Jellyfin",
link: "https://watch.justin.deal", link: "https://watch.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin.svg",
alt: "Jellyfin" alt: "Jellyfin",
tags: []
}, },
{ {
name: "Calibre-Web", name: "Calibre-Web",
link: "https://books.justin.deal", link: "https://books.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/calibre-web.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/calibre-web.svg",
alt: "Calibre-Web" alt: "Calibre-Web",
tags: []
} }
], ],
Infrastructure: [ Infrastructure: [
@ -90,37 +102,43 @@ export const services = {
name: "Pi-hole", name: "Pi-hole",
link: "https://pihole.justin.deal", link: "https://pihole.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/pi-hole.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/pi-hole.svg",
alt: "Pi-hole" alt: "Pi-hole",
tags: []
}, },
{ {
name: "Ntfy", name: "Ntfy",
link: "https://ntfy.justin.deal", link: "https://ntfy.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/ntfy.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/ntfy.svg",
alt: "Ntfy" alt: "Ntfy",
tags: []
}, },
{ {
name: "Vaultwarden", name: "Vaultwarden",
link: "https://passwords.justin.deal", link: "https://passwords.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vaultwarden.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vaultwarden.svg",
alt: "Vaultwarden" alt: "Vaultwarden",
tags: []
}, },
{ {
name: "Uptime Kuma", name: "Uptime Kuma",
link: "https://status.justin.deal", link: "https://status.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/uptime-kuma.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/uptime-kuma.svg",
alt: "Uptime Kuma" alt: "Uptime Kuma",
tags: []
}, },
{ {
name: "Authentik", name: "Authentik",
link: "https://auth.justin.deal", link: "https://auth.justin.deal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/authentik.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/authentik.svg",
alt: "Authentik" alt: "Authentik",
tags: []
}, },
{ {
name: "Traefik", name: "Traefik",
link: "https://proxy.justin.deal:8080", link: "https://proxy.justin.deal:8080",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/traefik.svg", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/traefik.svg",
alt: "Traefik" alt: "Traefik",
tags: []
} }
] ]
}; };