diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts index 2f4e04d..0a03096 100644 --- a/apps/backend/src/routes/services.ts +++ b/apps/backend/src/routes/services.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; -import { findBestIconMatch } from "../scanner/iconDb.js"; +import { suggestIconsForServiceName } from "../scanner/iconDb.js"; interface ServiceListQuery { deviceId?: string; @@ -75,23 +75,19 @@ export async function serviceRoutes(app: FastifyInstance): Promise { return reply.code(204).send(); }); - // Nachträgliches Ergänzen fehlender Favicons für bereits bestehende - // Dienste (z. B. von vor diesem Feature) - versucht für jeden Dienst OHNE - // Favicon einen passenden Treffer in der externen Icon-Datenbank zu - // finden (siehe scanner/iconDb.ts), anhand des aktuellen Anzeigenamens. - // Rein additiv: bestehende Favicons werden nie überschrieben, nur leere - // Felder aufgefüllt. - app.post("/api/services/backfill-favicons", async () => { - const services = serviceRepo.listServices(); - let updated = 0; + // Liefert für jeden Dienst OHNE Favicon eine Liste möglicher Treffer aus + // der externen Icon-Datenbank (siehe scanner/iconDb.ts), damit der Nutzer + // selbst auswählen kann statt dass automatisch geraten wird (mehrdeutige + // Namen wie "LXC Adguard" könnten sonst falsch zugeordnet werden). + app.get("/api/services/favicon-suggestions", async () => { + const services = serviceRepo.listServices().filter((s) => !s.favicon); + const suggestions = []; for (const service of services) { - if (service.favicon) continue; - const match = await findBestIconMatch(service.displayName); - if (match) { - serviceRepo.updateService(service.id, { favicon: match }); - updated++; + const candidates = await suggestIconsForServiceName(service.displayName); + if (candidates.length > 0) { + suggestions.push({ serviceId: service.id, serviceName: service.displayName, candidates }); } } - return { checked: services.length, updated }; + return { checked: services.length, suggestions }; }); } diff --git a/apps/backend/src/scanner/iconDb.ts b/apps/backend/src/scanner/iconDb.ts index a12c97f..ba320ba 100644 --- a/apps/backend/src/scanner/iconDb.ts +++ b/apps/backend/src/scanner/iconDb.ts @@ -80,31 +80,93 @@ export async function searchIconDb(query: string, limit = 24): Promise ({ name, url: `${CDN_BASE}/${name}.png` })); } +/** + * Generische Infrastruktur-/Container-Begriffe, die beim Zerlegen eines + * Dienstnamens in Wörter ignoriert werden - "LXC Adguard" soll als "Adguard" + * gesucht werden, nicht als "LXC" (das nie ein sinnvoller Icon-Treffer wäre). + */ +const STOPWORDS = new Set([ + "lxc", "vm", "docker", "container", "server", "service", "app", "box", + "host", "node", "instance", "srv", "ct", "the", "der", "die", "das", +]); + +function normalize(s: string): string { + return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function significantWords(name: string): string[] { + return name + .split(/[\s_-]+/) + .map((w) => normalize(w)) + .filter((w) => w.length >= 3 && !STOPWORDS.has(w)); +} + +async function exactMatch(candidate: string, names: string[]): Promise { + const nameSet = new Set(names); + if (nameSet.has(candidate)) return candidate; + const collapsed = candidate.replace(/-/g, ""); + const found = names.find((n) => n.replace(/-/g, "") === collapsed); + return found ?? null; +} + /** * Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/ - * Software-Namen ("Plex", "Pi-hole", "Adguard Home") - für den Fall, dass - * der Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts). - * Bewusst konservativ: nur ein Treffer, wenn der normalisierte Name exakt - * (oder nach Entfernen von Bindestrichen) übereinstimmt - eine reine - * Teilstring-Suche würde bei kurzen/generischen Namen zu viele falsche - * Zuordnungen liefern (z. B. "nas" -> "nas4free" wäre noch plausibel, aber - * "web" könnte alles Mögliche treffen). + * Software-Namen ("Plex", "LXC Adguard", "Pi-hole") - für den Fall, dass der + * Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts). Läuft + * unbeaufsichtigt während eines Scans, liefert deshalb nur EINEN Treffer + * (den ersten gefundenen: erst der ganze Name, dann Wort für Wort, generische + * Begriffe wie "LXC"/"Docker" werden dabei übersprungen) statt einer Auswahl. */ export async function findBestIconMatch(name: string): Promise { - const normalized = name - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (normalized.length < 2) return null; - const names = await getIconNames(); - const nameSet = new Set(names); - if (nameSet.has(normalized)) return `${CDN_BASE}/${normalized}.png`; - const collapsed = normalized.replace(/-/g, ""); - const exactCollapsed = names.find((n) => n.replace(/-/g, "") === collapsed); - if (exactCollapsed) return `${CDN_BASE}/${exactCollapsed}.png`; + const wholeName = normalize(name); + if (wholeName.length >= 2) { + const match = await exactMatch(wholeName, names); + if (match) return `${CDN_BASE}/${match}.png`; + } + + for (const word of significantWords(name)) { + const match = await exactMatch(word, names); + if (match) return `${CDN_BASE}/${match}.png`; + } return null; } + +/** + * Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten (ganzer + * Name + jedes einzelne aussagekräftige Wort, exakte Treffer UND + * Teilstring-Treffer) statt nur den ersten - für die interaktive + * "Fehlende Favicons ergänzen"-Übersicht, wo der Mensch selbst auswählt, + * welcher Treffer stimmt, statt dass automatisch geraten wird. + */ +export async function suggestIconsForServiceName(name: string, limit = 8): Promise { + const names = await getIconNames(); + const seen = new Set(); + const results: IconSearchResult[] = []; + + function addIfNew(iconName: string) { + if (seen.has(iconName)) return; + seen.add(iconName); + results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` }); + } + + const wholeName = normalize(name); + if (wholeName.length >= 2) { + const exact = await exactMatch(wholeName, names); + if (exact) addIfNew(exact); + } + + for (const word of significantWords(name)) { + const exact = await exactMatch(word, names); + if (exact) addIfNew(exact); + if (results.length >= limit) break; + for (const n of names) { + if (results.length >= limit) break; + if (n.includes(word)) addIfNew(n); + } + } + + return results.slice(0, limit); +} diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index ab32fa9..45064e8 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -3,14 +3,13 @@ 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, faSun, faMoon, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; import { rankServices, 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 { useTheme } from "../hooks/useTheme.js"; import { useCategories } from "../hooks/useCategories.js"; import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js"; import { useReadLater } from "../hooks/useReadLater.js"; @@ -116,7 +115,6 @@ function ReadLaterBox() { } export function HomePage() { - const [theme, toggleTheme] = useTheme(); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [resultsVisible, setResultsVisible] = useState(false); @@ -286,14 +284,6 @@ export function HomePage() { > - {/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht. @@ -334,7 +324,7 @@ export function HomePage() { dark:bg-white/[0.02]" > {hasFavorites ? ( -
+
{favoriteServices.length > 0 ? ( 0 ? ( -
+
) : null} -
+
Später lesen @@ -384,17 +374,17 @@ export function HomePage() {
{hasReadLaterChips ? ( -
+
{readLaterItems!.slice(0, readLaterLimit).map((item) => ( +
+ ); + } + return ( -
- {message ? {message} : null} - +
+
+ + {loading + ? "Suche …" + : suggestions && suggestions.length > 0 + ? `Vorschläge für ${suggestions.length} von ${checkedCount} Diensten ohne Favicon:` + : `Keine Vorschläge gefunden (${checkedCount} Dienste ohne Favicon geprüft).`} + + +
+ {suggestions && suggestions.length > 0 ? ( +
    + {suggestions.map((s) => ( +
  • + + {s.serviceName} + +
    + {s.candidates.map((c) => ( + + ))} +
    + +
  • + ))} +
+ ) : null}
); } @@ -696,9 +758,10 @@ export function ServicesPage() { Ausgeblendet ({hiddenCount})
-
+ + {sortColumn ? (
, + document.body ); } function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) { - const [hovered, setHovered] = useState(false); + const [anchorRect, setAnchorRect] = useState(null); + const buttonRef = useRef(null); + return ( -
- {hovered ? : null} + <> + {anchorRect ? : null} -
+ ); } @@ -116,11 +147,11 @@ function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect * zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem * weiteren Button überladen werden. * - * Beide Listen werden vor der Anzeige per Image-Preload geprüft - nur - * tatsächlich ladbare Bilder erscheinen. Eine URL, die nur plausibel aussieht - * aber ins Leere läuft (z. B. eine alte, vor diesem Fix gespeicherte - * Rate-Adresse), würde sonst als Buchstaben-Fallback auftauchen und die - * Auswahl unnötig vollstopfen. + * Beide Listen werden vor der Anzeige tatsächlich geladen UND als Bild + * dekodiert geprüft (siehe loadAndHash) - nur echte, funktionierende Icons + * erscheinen. Eine URL, die nur plausibel aussieht aber ins Leere läuft oder + * keine echte Bilddatei liefert, würde sonst als Buchstaben-Fallback + * auftauchen und die Auswahl unnötig vollstopfen. */ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) { const [open, setOpen] = useState(false); @@ -187,7 +218,7 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30" /> -
+
{showingSearch ? ( searching ? (

Suche …

diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx index c0ad21e..0b666ef 100644 --- a/packages/ui/src/FavoritesBar.tsx +++ b/packages/ui/src/FavoritesBar.tsx @@ -78,7 +78,7 @@ export function FavoritesBar({ {label}
) : null} -
+
{list.map((item) => { const ringColor = item.category ? categoryColors?.[item.category] : undefined; return ( @@ -95,14 +95,14 @@ export function FavoritesBar({ ? `${item.displayName} (${item.hostname}:${item.port})` : `${item.displayName} (${item.hostname})` } - className={`group flex w-14 flex-col items-center gap-1 rounded-xl p-1 + className={`group flex w-12 flex-col items-center gap-0.5 rounded-lg p-1 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${ onReorder ? "cursor-grab active:cursor-grabbing" : "" } ${draggedId === item.id ? "opacity-40" : ""}`} >