Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown

This commit is contained in:
2026-07-19 21:56:25 +02:00
parent 31e17ff77b
commit dc91a9aba9
24 changed files with 1655 additions and 180 deletions

View File

@@ -2,25 +2,38 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui";
import { rankServices, type Service } from "@launchpad/shared";
import { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.js";
function openService(service: Service) {
window.open(service.url, "_blank", "noopener,noreferrer");
function openItem(item: SearchResult) {
window.open(item.url, "_blank", "noopener,noreferrer");
}
async function toggleServiceFavorite(service: Service): Promise<Service> {
const res = await fetch(`/api/services/${service.id}`, {
async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
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: !service.favorite }),
body: JSON.stringify({ favorite: !item.favorite }),
});
if (!res.ok) {
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise<void> {
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})`);
}
}
export function HomePage() {
@@ -28,36 +41,56 @@ export function HomePage() {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading, isError } = useServices();
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const isSearching = query.trim().length > 0;
const isLoading = servicesLoading || bookmarksLoading;
const toggleFavorite = useMutation({
mutationFn: toggleServiceFavorite,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
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.
const visibleServices = useMemo(
() => (services ?? []).filter((s) => s.visible),
[services]
);
const allItems: SearchResult[] = useMemo(() => {
const visibleServices: SearchResult[] = (services ?? [])
.filter((s) => s.visible)
.map((s) => ({ ...s, kind: "service" as const }));
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
...b,
kind: "bookmark" as const,
}));
return [...visibleServices, ...bookmarkItems];
}, [services, bookmarks]);
const results = useMemo(
() => rankServices(visibleServices, query),
[visibleServices, query]
);
const results = useMemo(() => rankServices(allItems, query), [allItems, query]);
const favoriteServices = useMemo(
() =>
visibleServices
.filter((s) => s.favorite)
(services ?? [])
.filter((s) => s.visible && s.favorite)
.sort((a, b) => a.order - b.order),
[visibleServices]
[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
@@ -88,7 +121,7 @@ export function HomePage() {
} else if (e.key === "Enter") {
e.preventDefault();
const target = results[selectedIndex];
if (target) openService(target);
if (target) openItem(target);
} else if (e.key === "Escape") {
inputRef.current?.blur();
setQuery("");
@@ -100,6 +133,7 @@ export function HomePage() {
}, [results, selectedIndex, query]);
const isOnline = !healthError && health?.status === "ok";
const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0;
return (
<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">
@@ -132,9 +166,30 @@ export function HomePage() {
</div>
<div className="w-full max-w-xl">
{favoriteServices.length > 0 ? (
<div className="mb-4">
<FavoritesBar services={favoriteServices} onOpen={openService} />
{hasFavorites ? (
<div className="mb-4 flex flex-col gap-3">
{favoriteServices.length > 0 ? (
<FavoritesBar
items={favoriteServices}
label="Dienste"
onOpen={(item) => {
const service = favoriteServices.find((s) => s.id === item.id);
if (service) openItem({ ...service, kind: "service" });
}}
onReorder={(ids) => reorderServices.mutate(ids)}
/>
) : null}
{favoriteBookmarks.length > 0 ? (
<FavoritesBar
items={favoriteBookmarks}
label="Lesezeichen"
onOpen={(item) => {
const bookmark = favoriteBookmarks.find((b) => b.id === item.id);
if (bookmark) openItem({ ...bookmark, kind: "bookmark" });
}}
onReorder={(ids) => reorderBookmarks.mutate(ids)}
/>
) : null}
</div>
) : null}
@@ -142,29 +197,27 @@ export function HomePage() {
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Dienst suchen … z. B. „frigate“"
placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“"
hint="⌘K"
autoFocus
/>
{!isSearching ? null : 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-black/40 dark:text-white/40">Lade </p>
) : servicesError ? (
<p className="mt-4 text-center text-sm text-red-500">
Dienste konnten nicht geladen werden.
</p>
) : (
<ResultsList
services={results}
results={results}
selectedIndex={selectedIndex}
onHover={setSelectedIndex}
onOpen={openService}
onToggleFavorite={(service) => toggleFavorite.mutate(service)}
onOpen={openItem}
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
emptyLabel={
(visibleServices?.length ?? 0) === 0
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu."
allItems.length === 0
? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu."
: "Keine Treffer für deine Suche."
}
/>