Commit 3: Suche im Frontend mit echten Backend-Daten, Ranking, Tastatur-Navigation

This commit is contained in:
2026-07-19 02:00:13 +02:00
parent a5b998b421
commit f963f7eb4e
10 changed files with 224 additions and 16 deletions

View File

@@ -60,13 +60,14 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
- ✅ Persistentes Docker-Volume für die SQLite-Datenbank
- ✅ REST-API für Geräte & Dienste (`/api/devices`, `/api/services`), Zod-validiert,
mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`)
- ✅ Suche im Frontend gegen echte Backend-Daten (TanStack Query), inkl. Ranking-Logik
aus `packages/shared` und Tastatur-Navigation (Pfeiltasten, Enter, Escape)
Noch **nicht** enthalten (folgt in den nächsten Commits):
- Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung)
- Fuzzy-Suche im Frontend gegen echte Daten (Ranking-Logik existiert bereits in
`packages/shared`, ist aber noch nicht ans UI angebunden)
- Adminbereich, Plugin-System, PWA-Manifest, TanStack Router/Query, shadcn/ui, RHF
- Kategorien-Verwaltung & Favoriten-Toggle im UI
- Adminbereich, TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF
Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge.

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@launchpad/shared": "workspace:*",
"@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.51.23",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { SearchInput, StatusBadge } from "@launchpad/ui";
import type { HealthStatus } from "@launchpad/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
import { rankServices, type Service, type HealthStatus } from "@launchpad/shared";
import { useServices } from "./hooks/useServices.js";
type Theme = "light" | "dark";
@@ -55,33 +56,65 @@ function useBackendHealth() {
return { health, error };
}
function openService(service: Service) {
window.open(service.url, "_blank", "noopener,noreferrer");
}
export default function App() {
const [theme, toggleTheme] = useTheme();
const [query, setQuery] = useState("");
const { health, error } = useBackendHealth();
const [selectedIndex, setSelectedIndex] = useState(0);
const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading, isError } = useServices();
const inputRef = useRef<HTMLInputElement>(null);
const results = useMemo(
() => rankServices(services ?? [], query),
[services, query]
);
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
useEffect(() => {
setSelectedIndex(0);
}, [results.length, query]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const isSlash = e.key === "/" && document.activeElement !== inputRef.current;
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isSlash || isCmdK) {
e.preventDefault();
inputRef.current?.focus();
return;
}
if (e.key === "Escape") {
if (document.activeElement !== inputRef.current) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, Math.max(results.length - 1, 0)));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const target = results[selectedIndex];
if (target) openService(target);
} else if (e.key === "Escape") {
inputRef.current?.blur();
setQuery("");
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
}, [results, selectedIndex]);
const isOnline = !error && health?.status === "ok";
const isOnline = !healthError && health?.status === "ok";
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 dark:from-black dark:to-neutral-950">
<div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950">
<button
onClick={toggleTheme}
aria-label="Theme wechseln"
@@ -109,9 +142,31 @@ export default function App() {
hint="⌘K"
autoFocus
/>
{isLoading ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">
Lade Dienste
</p>
) : isError ? (
<p className="mt-4 text-center text-sm text-red-500">
Dienste konnten nicht geladen werden.
</p>
) : (
<ResultsList
services={results}
selectedIndex={selectedIndex}
onHover={setSelectedIndex}
onOpen={openService}
emptyLabel={
(services?.length ?? 0) === 0
? "Noch keine Dienste angelegt. Füge welche über die API hinzu."
: "Keine Treffer für deine Suche."
}
/>
)}
</div>
<div className="flex flex-col items-center gap-1">
<div className="flex flex-col items-center gap-1 pb-8">
<StatusBadge online={isOnline} label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"} />
{health ? (
<span className="text-xs text-black/30 dark:text-white/30">

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import type { Service } from "@launchpad/shared";
async function fetchServices(): Promise<Service[]> {
const res = await fetch("/api/services");
if (!res.ok) {
throw new Error(`Dienste konnten nicht geladen werden (HTTP ${res.status})`);
}
return res.json();
}
export function useServices() {
return useQuery({
queryKey: ["services"],
queryFn: fetchServices,
});
}

View File

@@ -1,10 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.js";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -9,11 +9,15 @@ Grundgerüst aus Commit 1.
- Zod-Validierung der Request-Bodies (`packages/shared/src/schemas.ts`)
- Repository-Layer über Drizzle (`apps/backend/src/db/repositories`)
## Commit 3 — Suche im Frontend
## Commit 3 — Suche im Frontend (erledigt)
- Anbindung von `rankServices` aus `packages/shared` an echte Backend-Daten
- TanStack Query für Datenhaltung, TanStack Router für Adminbereich vs. Startseite
- Tastatur-Navigation (Pfeiltasten, Enter öffnet Dienst)
- TanStack Query für Datenhaltung (`apps/frontend/src/hooks/useServices.ts`)
- Tastatur-Navigation (Pfeiltasten wählen, Enter öffnet Dienst in neuem Tab, Escape leert Suche)
- Neue UI-Komponente `ResultsList` in `packages/ui`
> TanStack Router wird bewusst erst mit dem Adminbereich (Commit 6) eingeführt,
> da es vorher keine zweite Route gibt, die er sinnvoll verwalten könnte.
## Commit 4 — Kategorien & Favoriten

View File

@@ -10,6 +10,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@launchpad/shared": "workspace:*",
"react": "^18.3.1"
},
"devDependencies": {

View File

@@ -0,0 +1,93 @@
import type { Service } from "@launchpad/shared";
export interface ResultsListProps {
services: Service[];
selectedIndex: number;
emptyLabel?: string;
onHover: (index: number) => void;
onOpen: (service: Service) => void;
}
/**
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an.
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
* gesteuert; diese Komponente ist rein darstellend + klickbar.
*/
export function ResultsList({
services,
selectedIndex,
emptyLabel = "Keine Dienste gefunden.",
onHover,
onOpen,
}: ResultsListProps) {
if (services.length === 0) {
return (
<div
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
text-sm text-black/40 dark:border-white/5 dark:bg-white/5 dark:text-white/40"
>
{emptyLabel}
</div>
);
}
return (
<ul
role="listbox"
className="mt-4 flex max-h-[60vh] flex-col overflow-y-auto rounded-2xl border
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
dark:bg-white/5"
>
{services.map((service, index) => {
const active = index === selectedIndex;
return (
<li key={service.id} role="option" aria-selected={active}>
<button
type="button"
onMouseEnter={() => onHover(index)}
onClick={() => onOpen(service)}
className={`flex w-full items-center gap-3 px-5 py-3 text-left transition-colors ${
active
? "bg-black/5 dark:bg-white/10"
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
}`}
>
{service.favicon ? (
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
) : (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-black dark:text-white">
{service.displayName}
</span>
<span className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname}
</span>
</span>
{service.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30">
{service.category}
</span>
) : null}
{service.favorite ? (
<span className="shrink-0 text-amber-500" aria-label="Favorit">
</span>
) : null}
</button>
</li>
);
})}
</ul>
);
}

View File

@@ -3,3 +3,6 @@ export type { SearchInputProps } from "./SearchInput.js";
export { StatusBadge } from "./StatusBadge.js";
export type { StatusBadgeProps } from "./StatusBadge.js";
export { ResultsList } from "./ResultsList.js";
export type { ResultsListProps } from "./ResultsList.js";

21
pnpm-lock.yaml generated
View File

@@ -57,6 +57,9 @@ importers:
'@launchpad/ui':
specifier: workspace:*
version: link:../../packages/ui
'@tanstack/react-query':
specifier: ^5.51.23
version: 5.101.2(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -101,6 +104,9 @@ importers:
packages/ui:
dependencies:
'@launchpad/shared':
specifier: workspace:*
version: link:../shared
react:
specifier: ^18.3.1
version: 18.3.1
@@ -950,6 +956,14 @@ packages:
cpu: [x64]
os: [win32]
'@tanstack/query-core@5.101.2':
resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
'@tanstack/react-query@5.101.2':
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
peerDependencies:
react: ^18 || ^19
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2426,6 +2440,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@tanstack/query-core@5.101.2': {}
'@tanstack/react-query@5.101.2(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.101.2
react: 18.3.1
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.7