feat(index): add search, fix gitea to forgejo
Some checks failed
build dist / build-dist (push) Failing after 27s

This commit is contained in:
z0x 2025-05-06 23:40:51 -04:00
parent 9b03ac7253
commit e95115d018
11 changed files with 365 additions and 72 deletions

View file

@ -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);
---
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${serviceName}`}
href={href}
target="_blank"
rel="noopener noreferrer"
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
class="flex items-center bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 transition rounded-md p-4 space-x-2"
>
<div>
<img
src={svgFile}
alt={`${serviceName} icon`}
class="sm:w-12 w-10 aspect-square"
/>
</div>
<span>{serviceName}</span>
</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">
<div class="flex-shrink-0 sm:w-12 w-10 aspect-square">
<img
src={svgFile}
alt=""
class="w-full h-full"
width="48"
height="48"
loading="lazy"
/>
</div>
<span class="font-medium">{service.serviceName}</span>
</div>
</a>

View 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
View file

@ -0,0 +1,2 @@
export const DEFAULT_DOMAIN = "home.arpa";
export const DEFAULT_ICON_PATH = "/icons";

84
src/config/services.ts Normal file
View 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
View 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))
}

View file

@ -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";
---
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="Homepage" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Homepage</title>
</head>
<body class="flex items-center justify-center min-h-screen">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{
services.map((service) => (
<ServiceCard
serviceName={service.serviceName}
serviceUrl={service.serviceUrl}
/>
))
}
</div>
<body class="flex items-center justify-center min-h-screen bg-background">
<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 service={service} />
))
}
</div>
</main>
<script>
import { ServiceSearch } from "@/components/ServiceSearch";
new ServiceSearch();
</script>
</body>
</html>

28
src/utils/service.ts Normal file
View 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),
);
}