import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; import { rankSearchResults, type SearchResult } from "@launchpad/shared"; import { useServices } from "../hooks/useServices.js"; import { useBookmarks } from "../hooks/useBookmarks.js"; import { useDevices } from "../hooks/useDevices.js"; import { useSettings } from "../hooks/useSettings.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useCategories } from "../hooks/useCategories.js"; import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js"; import { useReadLater } from "../hooks/useReadLater.js"; async function toggleFavoriteRequest(item: SearchResult): Promise { const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`; const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ favorite: !item.favorite }), }); if (!res.ok) { throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`); } } async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise { const path = kind === "service" ? "/api/services/reorder" : "/api/bookmarks/reorder"; const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(orderedIds.map((id, index) => ({ id, order: index }))), }); if (!res.ok) { throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`); } } async function saveReadLaterRequest(url: string) { const res = await fetch("/api/read-later", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Konnte nicht gespeichert werden (HTTP ${res.status})`); } return res.json(); } function ReadLaterBox() { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [url, setUrl] = useState(""); const inputRef = useRef(null); const mutation = useMutation({ mutationFn: () => saveReadLaterRequest(url.trim()), onSuccess: () => { setUrl(""); setOpen(false); queryClient.invalidateQueries({ queryKey: ["read-later"] }); }, }); function handleSubmit(e: FormEvent) { e.preventDefault(); if (!url.trim()) return; mutation.mutate(); } if (!open) { return ( ); } return (
setUrl(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setUrl(""); setOpen(false); } }} placeholder="Link zum Später-Lesen hier einfügen …" className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-1.5 text-sm text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30 dark:focus:border-indigo-400/40" />
); } export function HomePage() { const [query, setQuery] = useState( () => new URLSearchParams(window.location.search).get("q") ?? "" ); const [selectedIndex, setSelectedIndex] = useState(0); const [resultsVisible, setResultsVisible] = useState( () => new URLSearchParams(window.location.search).get("q") !== null ); const { health, error: healthError } = useBackendHealth(); const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices(); const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks(); const { data: devices } = useDevices(); const { data: settings } = useSettings(); const { data: categories } = useCategories(); const { data: recentVisits } = useRecentVisits(); const { data: readLaterItems } = useReadLater(); const inputRef = useRef(null); const searchContainerRef = useRef(null); const queryClient = useQueryClient(); const isSearching = query.trim().length > 0; const isLoading = servicesLoading || bookmarksLoading; function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) { if ("kind" in item && item.kind === "device") return; // Geräte haben keine eigene URL, nicht anklickbar window.open(item.url, "_blank", "noopener,noreferrer"); if ("kind" in item && (item.kind === "service" || item.kind === "bookmark") && item.id) { recordVisit(item.kind, item.id).then(() => { queryClient.invalidateQueries({ queryKey: ["recent-visits"] }); }); } } const categoryColors = useMemo(() => { const map: Record = {}; for (const c of categories ?? []) { if (c.color) map[c.name] = c.color; } return map; }, [categories]); const toggleFavorite = useMutation({ mutationFn: toggleFavoriteRequest, onSuccess: (_data, item) => { queryClient.invalidateQueries({ queryKey: [item.kind === "service" ? "services" : "bookmarks"] }); }, }); const reorderServices = useMutation({ mutationFn: (ids: string[]) => reorderRequest("service", ids), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), }); const reorderBookmarks = useMutation({ mutationFn: (ids: string[]) => reorderRequest("bookmark", ids), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), }); // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer, // siehe Admin -> Dienste) tauchen in der Suche nicht auf. Geräte und // Später-lesen-Einträge sind jetzt ebenfalls durchsuchbar (siehe // rankSearchResults: Dienste stehen dabei immer zuerst, Geräte immer // zuletzt - Geräte haben keine eigene URL zum Öffnen, siehe ResultsList). const allItems: SearchResult[] = useMemo(() => { const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname])); const deviceOnlineById = new Map((devices ?? []).map((d) => [d.id, d.online])); const visibleServices: SearchResult[] = (services ?? []) .filter((s) => s.visible) .map((s) => ({ ...s, kind: "service" as const, deviceHostname: deviceHostnameById.get(s.deviceId) ?? null, deviceOnline: deviceOnlineById.get(s.deviceId) ?? false, })); const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({ ...b, kind: "bookmark" as const, })); const deviceItems: SearchResult[] = (devices ?? []).map((d) => ({ kind: "device" as const, id: d.id, displayName: d.hostname, hostname: d.hostname, ip: d.ip, mac: d.mac, online: d.online, favicon: null, category: null, favorite: false, order: 0, alias: [d.ip, d.mac].filter((v): v is string => !!v), description: d.ip, })); const readLaterSearchItems: SearchResult[] = (readLaterItems ?? []).map((r) => ({ kind: "readlater" as const, id: r.id, displayName: r.displayName, hostname: "", url: r.url, favicon: r.favicon, category: null, favorite: false, order: 0, alias: [], description: null, })); return [...visibleServices, ...bookmarkItems, ...deviceItems, ...readLaterSearchItems]; }, [services, bookmarks, devices, readLaterItems]); const results = useMemo(() => rankSearchResults(allItems, query), [allItems, query]); const favoriteServices = useMemo( () => (services ?? []) .filter((s) => s.visible && s.favorite) .sort((a, b) => a.order - b.order), [services] ); const favoriteBookmarks = useMemo( () => (bookmarks ?? []).filter((b) => b.favorite).sort((a, b) => a.order - b.order), [bookmarks] ); // Auswahl zurücksetzen, sobald sich die Trefferliste ändert useEffect(() => { setSelectedIndex(0); }, [results.length, query]); // Beim Tippen wieder einblenden (z. B. nachdem per Klick-außerhalb // zugeklappt wurde und man weitertippt). useEffect(() => { if (isSearching) setResultsVisible(true); }, [isSearching]); // Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein // (Desktop-Verhalten, Text bleibt erhalten). Bewusst gegen das tatsächlich // gerenderte Listbox-Element geprüft (closest('[role="listbox"]')), nicht // gegen dessen umgebende Scroll-Wrapper-Divs - die sind aus Layoutgründen // auf volle Resthöhe gestreckt (siehe max-h-full-Trick in ResultsList), // ein Ref darauf hätte fast den ganzen Bildschirm als "innerhalb" gezählt. useEffect(() => { function onPointerDown(e: MouseEvent) { const target = e.target as HTMLElement; const insideSearch = searchContainerRef.current?.contains(target) ?? false; const insideResults = target.closest('[role="listbox"]') !== null; if (!insideSearch && !insideResults) { setResultsVisible(false); } } document.addEventListener("mousedown", onPointerDown); return () => document.removeEventListener("mousedown", onPointerDown); }, []); 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 (document.activeElement !== inputRef.current) return; if (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern 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) openItem(target); } else if (e.key === "Escape") { inputRef.current?.blur(); setQuery(""); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [results, selectedIndex, query]); const isOnline = !healthError && health?.status === "ok"; const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0; const readLaterLimit = settings?.readLaterLimit ?? 5; const hasReadLaterChips = readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0; const showShelf = !isSearching || !resultsVisible; const showResults = isSearching && resultsVisible; return (
{/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht. Nur im "aktiv suchend"-Zustand shrink-0 + scrollender Bereich darunter - sonst normaler Dokumentfluss, damit der Footer direkt nach dem Inhalt folgt statt mit einer riesigen Lücke ganz unten zu kleben (siehe showResults-Fallunterscheidung oben). */}

LaunchPad

Tippe, um deine Homelab-Dienste sofort zu öffnen.

setQuery(e.target.value)} onClear={() => { setQuery(""); inputRef.current?.focus(); }} onFocus={() => setResultsVisible(true)} placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“" hint="⌘K" autoFocus />
{showShelf ? (
{hasFavorites ? (
{favoriteServices.length > 0 ? ( { const service = favoriteServices.find((s) => s.id === item.id); if (service) openItem({ ...service, kind: "service" }); }} onReorder={(ids) => reorderServices.mutate(ids)} /> ) : null} {favoriteBookmarks.length > 0 ? ( { const bookmark = favoriteBookmarks.find((b) => b.id === item.id); if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); }} onReorder={(ids) => reorderBookmarks.mutate(ids)} /> ) : null}
) : null} {recentVisits && recentVisits.length > 0 ? (
{ const original = recentVisits.find((r) => r.id === item.id); if (original) openItem(original); }} />
) : null}
Später lesen
{hasReadLaterChips ? (
{readLaterItems!.slice(0, readLaterLimit).map((item) => ( ))}
) : (

Noch nichts gemerkt.

)}
) : null}
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */} {showResults ? (
{isLoading ? (

Lade …

) : servicesError ? (

Dienste konnten nicht geladen werden.

) : ( toggleFavorite.mutate(item)} emptyLabel={ allItems.length === 0 ? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu." : "Keine Treffer für deine Suche." } /> )}
) : null}
{health ? ( v{health.version} · läuft seit {health.uptimeSeconds}s ) : null}
); }