feat(index): add search, fix gitea to forgejo
Some checks failed
build dist / build-dist (push) Failing after 27s
Some checks failed
build dist / build-dist (push) Failing after 27s
This commit is contained in:
parent
9b03ac7253
commit
e95115d018
11 changed files with 365 additions and 72 deletions
4
bun.lock
4
bun.lock
|
@ -314,7 +314,7 @@
|
||||||
|
|
||||||
"array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,10 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.7.10",
|
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
|
"astro": "^5.7.10",
|
||||||
"tailwindcss": "^4.1.5"
|
"tailwindcss": "^4.1.5"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
|
Before Width: | Height: | Size: 633 B After Width: | Height: | Size: 633 B |
|
@ -1,34 +1,40 @@
|
||||||
---
|
---
|
||||||
export interface ServiceProps {
|
import type { Service } from "@/config/services";
|
||||||
serviceName: string;
|
import {
|
||||||
serviceUrl?: string;
|
getServiceAriaLabel,
|
||||||
|
getServiceIconPath,
|
||||||
|
getServiceUrl,
|
||||||
|
} from "@/utils/service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
service: Service;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serviceName, serviceUrl } = Astro.props;
|
const { service, className } = Astro.props;
|
||||||
|
const href = getServiceUrl(service);
|
||||||
const formattedName = serviceName.toLowerCase().replace(/\s+/g, "-");
|
const svgFile = getServiceIconPath(service);
|
||||||
const svgFile = `/icons/${formattedName}.svg`;
|
const ariaLabel = getServiceAriaLabel(service);
|
||||||
const defaultDomain = "home.arpa";
|
|
||||||
|
|
||||||
const href = serviceUrl || `https://${formattedName}.${defaultDomain}`;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`Open ${serviceName}`}
|
aria-label={ariaLabel}
|
||||||
|
class={`block transition-all duration-200 hover:scale-105 focus:scale-105 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-lg ${className || ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex items-center gap-3 bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground transition-colors rounded-lg p-4">
|
||||||
class="flex items-center bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 transition rounded-md p-4 space-x-2"
|
<div class="flex-shrink-0 sm:w-12 w-10 aspect-square">
|
||||||
>
|
<img
|
||||||
<div>
|
src={svgFile}
|
||||||
<img
|
alt=""
|
||||||
src={svgFile}
|
class="w-full h-full"
|
||||||
alt={`${serviceName} icon`}
|
width="48"
|
||||||
class="sm:w-12 w-10 aspect-square"
|
height="48"
|
||||||
/>
|
loading="lazy"
|
||||||
</div>
|
/>
|
||||||
<span>{serviceName}</span>
|
</div>
|
||||||
</div>
|
<span class="font-medium">{service.serviceName}</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
192
src/components/ServiceSearch.ts
Normal file
192
src/components/ServiceSearch.ts
Normal file
|
@ -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 = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="w-full max-w-2xl mx-4">
|
||||||
|
<div class="bg-background rounded-lg shadow-lg">
|
||||||
|
<div class="p-4 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search services... (Press Esc to close)"
|
||||||
|
class="w-full px-4 py-2 bg-muted rounded-md focus:outline-none focus:ring-2 focus:ring-ring text-base"
|
||||||
|
/>
|
||||||
|
<button class="p-2 text-muted-foreground hover:text-foreground transition-colors" aria-label="Close search">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[70vh] md:max-h-[60vh] overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-4 py-4 text-left hover:bg-accent hover:text-accent-foreground transition-colors text-base flex items-center gap-3 ${
|
||||||
|
index === this.selectedIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: ""
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0 w-8 h-8">
|
||||||
|
<img
|
||||||
|
src="${getServiceIconPath(service)}"
|
||||||
|
alt=""
|
||||||
|
class="w-full h-full"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>${service.serviceName}</span>
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.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 = `
|
||||||
|
<div class="px-4 py-4 text-muted-foreground text-base">
|
||||||
|
No services found
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
}
|
2
src/config/constants.ts
Normal file
2
src/config/constants.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const DEFAULT_DOMAIN = "home.arpa";
|
||||||
|
export const DEFAULT_ICON_PATH = "/icons";
|
84
src/config/services.ts
Normal file
84
src/config/services.ts
Normal file
|
@ -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",
|
||||||
|
},
|
||||||
|
];
|
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
|
@ -1,56 +1,31 @@
|
||||||
---
|
---
|
||||||
import "/src/css/app.css";
|
import "/src/css/globals.css";
|
||||||
import ServiceCard from "@/components/ServiceCard.astro";
|
import ServiceCard from "@/components/ServiceCard.astro";
|
||||||
|
import { services } from "@/config/services";
|
||||||
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" },
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
name="viewport"
|
<meta name="description" content="Homepage" />
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
||||||
/>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Homepage</title>
|
<title>Homepage</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="flex items-center justify-center min-h-screen">
|
<body class="flex items-center justify-center min-h-screen bg-background">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<main class="container mx-auto px-4 py-8">
|
||||||
{
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
services.map((service) => (
|
{
|
||||||
<ServiceCard
|
services.map((service) => (
|
||||||
serviceName={service.serviceName}
|
<ServiceCard service={service} />
|
||||||
serviceUrl={service.serviceUrl}
|
))
|
||||||
/>
|
}
|
||||||
))
|
</div>
|
||||||
}
|
</main>
|
||||||
</div>
|
<script>
|
||||||
|
import { ServiceSearch } from "@/components/ServiceSearch";
|
||||||
|
new ServiceSearch();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
28
src/utils/service.ts
Normal file
28
src/utils/service.ts
Normal file
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue