Compare commits

..

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

7 changed files with 48 additions and 420 deletions

View File

@ -1,25 +1,25 @@
{ {
"hash": "7113b21e", "hash": "6c87a632",
"configHash": "2cd4a4ea", "configHash": "e55fbbff",
"lockfileHash": "53cd0e09", "lockfileHash": "53cd0e09",
"browserHash": "f4d46c12", "browserHash": "e114ea7e",
"optimized": { "optimized": {
"astro > cssesc": { "astro > cssesc": {
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js", "src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
"file": "astro___cssesc.js", "file": "astro___cssesc.js",
"fileHash": "dc615560", "fileHash": "7ff5e5f3",
"needsInterop": true "needsInterop": true
}, },
"astro > aria-query": { "astro > aria-query": {
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js", "src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
"file": "astro___aria-query.js", "file": "astro___aria-query.js",
"fileHash": "53d05d83", "fileHash": "c7870714",
"needsInterop": true "needsInterop": true
}, },
"astro > axobject-query": { "astro > axobject-query": {
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js", "src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
"file": "astro___axobject-query.js", "file": "astro___axobject-query.js",
"fileHash": "35e7ec58", "fileHash": "dc6eafc6",
"needsInterop": true "needsInterop": true
} }
}, },

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import "../styles/global.css";
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i" href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
rel="stylesheet" rel="stylesheet"
/> />
<script src="https://unpkg.com/alpinejs" defer></script>
<slot name="head" /> <slot name="head" />
</head> </head>
<body class="zag-bg zag-text zag-transition font-mono"> <body class="zag-bg zag-text zag-transition font-mono">

View File

@ -2,8 +2,7 @@
import { GLOBAL } from "../../lib/variables"; import { GLOBAL } from "../../lib/variables";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import Section from "../../components/common/Section.astro"; import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro"; import ServiceCard from "../../components/common/ServiceCard.astro";
import CategorySection from "../../components/homelab/CategorySection.astro";
import { services } from "./services.ts"; import { services } from "./services.ts";
--- ---
@ -25,132 +24,29 @@ import { services } from "./services.ts";
</Fragment> </Fragment>
<Section class="my-8"> <Section class="my-8">
<div x-data="{ <div class="flex items-center gap-4 pt-8 pb-4">
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">
<SearchBar
placeholder="Search services..."
ariaLabel="Search services"
/>
</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> <h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
</div> </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 Results</p>
</div>
<!-- Service categories -->
<div id="app-list">
{Object.entries(services).map(([category, apps]) => ( {Object.entries(services).map(([category, apps]) => (
<CategorySection category={category} apps={apps} /> <div class="mb-8">
<h3 class="text-xl font-semibold mb-4">{category}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{apps.length > 0 ? (
apps.map(app => (
<ServiceCard
name={app.name}
href={app.link}
img={app.icon}
alt={app.name}
key={app.link}
/>
))
) : (
<p class="text-center col-span-full">Coming soon...</p>
)}
</div>
</div>
))} ))}
</div>
</div>
</Section> </Section>
</Layout> </Layout>

View File

@ -12,50 +12,43 @@ 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: [
@ -63,22 +56,19 @@ 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: [
@ -86,15 +76,13 @@ 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: [
@ -102,43 +90,37 @@ 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: []
} }
] ]
}; };