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} -
- {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), + ); +}