diff --git a/bun.lock b/bun.lock
index ec0cf1f..29868d1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -314,7 +314,7 @@
"array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
- "astro": ["astro@5.7.10", "", { "dependencies": { "@astrojs/compiler": "^2.11.0", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.1", "@astrojs/telemetry": "3.2.1", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.4.1", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-9TQcFZqP2w6//JXXUHfw8/5PX7KUx9EkG5O3m+hISuyeUztvjY1q5+p7+C5HiXyg24Zs3KkpieoL5BGRXGCAGA=="],
+ "astro": ["astro@5.7.11", "", { "dependencies": { "@astrojs/compiler": "^2.11.0", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.1", "@astrojs/telemetry": "3.2.1", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-9qRVwp8pue3isddLBnTexJsmKFpmms9Fo7Ss+3yrC0aINvbHKpD7q6qf52BtfQEk2xJgyx3SQy3dUsuD90sEqQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -814,7 +814,7 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
- "unifont": ["unifont@0.4.1", "", { "dependencies": { "css-tree": "^3.0.0", "ohash": "^2.0.0" } }, "sha512-zKSY9qO8svWYns+FGKjyVdLvpGPwqmsCjeJLN1xndMiqxHWBAhoWDMYMG960MxeV48clBmG+fDP59dHY1VoZvg=="],
+ "unifont": ["unifont@0.5.0", "", { "dependencies": { "css-tree": "^3.0.0", "ohash": "^2.0.0" } }, "sha512-4DueXMP5Hy4n607sh+vJ+rajoLu778aU3GzqeTCqsD/EaUcvqZT9wPC8kgK6Vjh22ZskrxyRCR71FwNOaYn6jA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
diff --git a/package.json b/package.json
index 90dd25f..4e0662c 100644
--- a/package.json
+++ b/package.json
@@ -10,10 +10,10 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^5.7.10",
"@astrojs/check": "^0.9.4",
"@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.5",
+ "astro": "^5.7.10",
"tailwindcss": "^4.1.5"
},
"trustedDependencies": [
diff --git a/public/icons/gitea.svg b/public/icons/forgejo.svg
similarity index 100%
rename from public/icons/gitea.svg
rename to public/icons/forgejo.svg
diff --git a/src/components/ServiceCard.astro b/src/components/ServiceCard.astro
index 42ac81e..24e2cb7 100644
--- a/src/components/ServiceCard.astro
+++ b/src/components/ServiceCard.astro
@@ -1,34 +1,40 @@
---
-export interface ServiceProps {
- serviceName: string;
- serviceUrl?: string;
+import type { Service } from "@/config/services";
+import {
+ getServiceAriaLabel,
+ getServiceIconPath,
+ getServiceUrl,
+} from "@/utils/service";
+
+interface Props {
+ service: Service;
+ className?: string;
}
-const { serviceName, serviceUrl } = Astro.props;
-
-const formattedName = serviceName.toLowerCase().replace(/\s+/g, "-");
-const svgFile = `/icons/${formattedName}.svg`;
-const defaultDomain = "home.arpa";
-
-const href = serviceUrl || `https://${formattedName}.${defaultDomain}`;
+const { service, className } = Astro.props;
+const href = getServiceUrl(service);
+const svgFile = getServiceIconPath(service);
+const ariaLabel = getServiceAriaLabel(service);
---
-
-
-

-
-
{serviceName}
-
+
+
+

+
+
{service.serviceName}
+
diff --git a/src/components/ServiceSearch.ts b/src/components/ServiceSearch.ts
new file mode 100644
index 0000000..a04affd
--- /dev/null
+++ b/src/components/ServiceSearch.ts
@@ -0,0 +1,192 @@
+import { services } from "@/config/services";
+import {
+ getServiceIconPath,
+ getServiceUrl,
+ searchServices,
+} from "@/utils/service";
+
+export class ServiceSearch {
+ private isOpen = false;
+ private query = "";
+ private results: typeof services = [];
+ private selectedIndex = 0;
+ private container: HTMLDivElement | null = null;
+ private searchButton: HTMLButtonElement | null = null;
+
+ constructor() {
+ this.init();
+ }
+
+ private init() {
+ // Create search button
+ this.searchButton = document.createElement("button");
+ this.searchButton.className =
+ "fixed bottom-4 right-4 md:hidden bg-primary text-primary-foreground p-3 rounded-full shadow-lg z-40";
+ this.searchButton.innerHTML = `
+
+ `;
+ this.searchButton.addEventListener("click", () => this.open());
+ document.body.appendChild(this.searchButton);
+
+ // Create container
+ this.container = document.createElement("div");
+ this.container.className =
+ "fixed inset-0 bg-black/50 flex items-start justify-center pt-[10vh] md:pt-[20vh] z-50 hidden";
+ this.container.innerHTML = `
+
+ `;
+
+ document.body.appendChild(this.container);
+
+ // Get elements
+ const input = this.container.querySelector("input");
+ const resultsContainer = this.container.querySelector(".max-h-\\[70vh\\]");
+ const closeButton = this.container.querySelector("button");
+
+ if (!input || !resultsContainer || !closeButton) return;
+
+ // Add event listeners
+ input.addEventListener("input", (e) => {
+ this.query = (e.target as HTMLInputElement).value;
+ this.updateResults();
+ });
+
+ closeButton.addEventListener("click", () => this.close());
+
+ document.addEventListener("keydown", this.handleKeyDown.bind(this));
+ }
+
+ private handleKeyDown(e: KeyboardEvent) {
+ // Open search with Ctrl+K or Cmd+K
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ this.open();
+ }
+
+ // Close search with Escape
+ if (e.key === "Escape") {
+ this.close();
+ }
+
+ // Navigate results with arrow keys
+ if (this.isOpen) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ this.selectedIndex = Math.min(
+ this.selectedIndex + 1,
+ this.results.length - 1,
+ );
+ this.updateResults();
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
+ this.updateResults();
+ } else if (e.key === "Enter" && this.results[this.selectedIndex]) {
+ e.preventDefault();
+ const service = this.results[this.selectedIndex];
+ window.open(getServiceUrl(service), "_blank");
+ this.close();
+ }
+ }
+ }
+
+ private updateResults() {
+ if (!this.container) return;
+
+ const resultsContainer = this.container.querySelector(".max-h-\\[70vh\\]");
+ if (!resultsContainer) return;
+
+ if (this.query.trim()) {
+ this.results = searchServices(services, this.query);
+ } else {
+ this.results = [];
+ }
+
+ if (this.results.length > 0) {
+ resultsContainer.innerHTML = this.results
+ .map(
+ (service, index) => `
+
+ `,
+ )
+ .join("");
+
+ // Add click handlers
+ resultsContainer.querySelectorAll("button").forEach((button, index) => {
+ button.addEventListener("click", () => {
+ const service = this.results[index];
+ window.open(getServiceUrl(service), "_blank");
+ this.close();
+ });
+ });
+ } else if (this.query) {
+ resultsContainer.innerHTML = `
+
+ No services found
+
+ `;
+ } else {
+ resultsContainer.innerHTML = "";
+ }
+ }
+
+ private open() {
+ if (!this.container) return;
+ this.isOpen = true;
+ this.container.classList.remove("hidden");
+ const input = this.container.querySelector("input");
+ if (input) {
+ input.value = "";
+ input.focus();
+ }
+ this.query = "";
+ this.results = [];
+ this.selectedIndex = 0;
+ this.updateResults();
+ }
+
+ private close() {
+ if (!this.container) return;
+ this.isOpen = false;
+ this.container.classList.add("hidden");
+ }
+}
diff --git a/src/config/constants.ts b/src/config/constants.ts
new file mode 100644
index 0000000..8d6cb24
--- /dev/null
+++ b/src/config/constants.ts
@@ -0,0 +1,2 @@
+export const DEFAULT_DOMAIN = "home.arpa";
+export const DEFAULT_ICON_PATH = "/icons";
diff --git a/src/config/services.ts b/src/config/services.ts
new file mode 100644
index 0000000..11d88c5
--- /dev/null
+++ b/src/config/services.ts
@@ -0,0 +1,84 @@
+export interface Service {
+ serviceName: string;
+ serviceUrl?: string;
+}
+
+export const services: Service[] = [
+ {
+ serviceName: "Beszel",
+ },
+ {
+ serviceName: "Blog",
+ serviceUrl: "https://blog.z0x.home.arpa",
+ },
+ {
+ serviceName: "Cup",
+ },
+ {
+ serviceName: "Dockge",
+ },
+ {
+ serviceName: "Dozzle",
+ },
+ {
+ serviceName: "Element",
+ },
+ {
+ serviceName: "Forgejo",
+ serviceUrl: "https://git.home.arpa",
+ },
+ {
+ serviceName: "Home Assistant",
+ serviceUrl: "https://ha.home.arpa",
+ },
+ {
+ serviceName: "Immich",
+ },
+ {
+ serviceName: "Maloja",
+ },
+ {
+ serviceName: "Navidrome",
+ serviceUrl: "https://aonsoku.home.arpa/",
+ },
+ {
+ serviceName: "Radicale",
+ },
+ {
+ serviceName: "Redlib",
+ },
+ {
+ serviceName: "Roundcube",
+ },
+ {
+ serviceName: "Scrutiny",
+ },
+ {
+ serviceName: "SFTPGo",
+ },
+ {
+ serviceName: "Synapse Admin",
+ },
+ {
+ serviceName: "Umami",
+ },
+ {
+ serviceName: "Unifi",
+ },
+ {
+ serviceName: "Upsnap",
+ },
+ {
+ serviceName: "Vaultwarden",
+ },
+ {
+ serviceName: "WG Easy",
+ },
+ {
+ serviceName: "Zyxel",
+ serviceUrl: "https://10.0.0.1",
+ },
+ {
+ serviceName: "z0x",
+ },
+];
diff --git a/src/css/app.css b/src/css/globals.css
similarity index 100%
rename from src/css/app.css
rename to src/css/globals.css
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 62d2587..daff5f2 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,56 +1,31 @@
---
-import "/src/css/app.css";
+import "/src/css/globals.css";
import ServiceCard from "@/components/ServiceCard.astro";
-
-const services = [
- { serviceName: "WG Easy" },
- { serviceName: "Beszel" },
- { serviceName: "Blog", serviceUrl: "https://blog.z0x.home.arpa" },
- { serviceName: "Cup" },
- { serviceName: "Dockge" },
- { serviceName: "Dozzle" },
- { serviceName: "Element" },
- { serviceName: "Gitea", serviceUrl: "https://git.home.arpa" },
- { serviceName: "Home Assistant", serviceUrl: "https://ha.home.arpa" },
- { serviceName: "Immich" },
- { serviceName: "Maloja" },
- { serviceName: "Navidrome", serviceUrl: "https://aonsoku.home.arpa/" },
- { serviceName: "Radicale" },
- { serviceName: "Redlib" },
- { serviceName: "Roundcube" },
- { serviceName: "Scrutiny" },
- { serviceName: "SFTPGo" },
- { serviceName: "Synapse Admin" },
- { serviceName: "Umami" },
- { serviceName: "Unifi" },
- { serviceName: "Upsnap" },
- { serviceName: "Vaultwarden" },
- { serviceName: "Zyxel", serviceUrl: "https://10.0.0.1" },
- { serviceName: "z0x" },
-];
+import { services } from "@/config/services";
---
-
+
+
Homepage
-
-
- {
- services.map((service) => (
-
- ))
- }
-
+
+
+
+ {
+ services.map((service) => (
+
+ ))
+ }
+
+
+
diff --git a/src/utils/service.ts b/src/utils/service.ts
new file mode 100644
index 0000000..7c71a9a
--- /dev/null
+++ b/src/utils/service.ts
@@ -0,0 +1,28 @@
+import { DEFAULT_DOMAIN, DEFAULT_ICON_PATH } from "@/config/constants";
+import type { Service } from "@/config/services";
+
+export function getServiceUrl(service: Service): string {
+ return (
+ service.serviceUrl ||
+ `https://${formatServiceName(service.serviceName)}.${DEFAULT_DOMAIN}`
+ );
+}
+
+export function getServiceIconPath(service: Service): string {
+ return `${DEFAULT_ICON_PATH}/${formatServiceName(service.serviceName)}.svg`;
+}
+
+export function formatServiceName(name: string): string {
+ return name.toLowerCase().replace(/\s+/g, "-");
+}
+
+export function getServiceAriaLabel(service: Service): string {
+ return `Open ${service.serviceName} service`;
+}
+
+export function searchServices(services: Service[], query: string): Service[] {
+ const searchTerm = query.toLowerCase().trim();
+ return services.filter((service) =>
+ service.serviceName.toLowerCase().includes(searchTerm),
+ );
+}