Add's the Ability to Search On Categories and Predefined Tags
This commit is contained in:
parent
dc75b39596
commit
4409625260
@ -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>
|
||||||
|
@ -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: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user