generated from Dicken/dickendock
round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege
This commit is contained in:
@@ -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<void> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,31 +80,93 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
|
||||
return matches.map((name) => ({ 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<string | null> {
|
||||
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<string | null> {
|
||||
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<IconSearchResult[]> {
|
||||
const names = await getIconNames();
|
||||
const seen = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<FontAwesomeIcon icon={faGear} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="Theme wechseln"
|
||||
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
|
||||
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
|
||||
>
|
||||
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht.
|
||||
@@ -334,7 +324,7 @@ export function HomePage() {
|
||||
dark:bg-white/[0.02]"
|
||||
>
|
||||
{hasFavorites ? (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2 p-2.5">
|
||||
{favoriteServices.length > 0 ? (
|
||||
<FavoritesBar
|
||||
items={favoriteServices}
|
||||
@@ -363,7 +353,7 @@ export function HomePage() {
|
||||
) : null}
|
||||
|
||||
{recentVisits && recentVisits.length > 0 ? (
|
||||
<div className="p-3">
|
||||
<div className="p-2.5">
|
||||
<FavoritesBar
|
||||
items={recentVisits}
|
||||
label="Zuletzt besucht"
|
||||
@@ -376,7 +366,7 @@ export function HomePage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-3">
|
||||
<div className="p-2.5">
|
||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
|
||||
Später lesen
|
||||
@@ -384,17 +374,17 @@ export function HomePage() {
|
||||
<ReadLaterBox />
|
||||
</div>
|
||||
{hasReadLaterChips ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{readLaterItems!.slice(0, readLaterLimit).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
|
||||
title={item.displayName}
|
||||
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]"
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border
|
||||
border-black/10 bg-white/80 shadow-sm transition-transform
|
||||
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ServicePatch {
|
||||
port?: number;
|
||||
https?: boolean;
|
||||
url?: string;
|
||||
favicon?: string | null;
|
||||
}
|
||||
|
||||
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
|
||||
@@ -503,42 +504,103 @@ function SortableHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillFaviconsButton() {
|
||||
const queryClient = useQueryClient();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
interface FaviconSuggestion {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
candidates: { name: string; url: string }[];
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/services/backfill-favicons", { method: "POST" });
|
||||
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.json() as Promise<{ checked: number; updated: number }>;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setMessage(
|
||||
result.updated > 0
|
||||
? `${result.updated} von ${result.checked} Diensten mit einem passenden Favicon aus der Icon-Datenbank ergänzt.`
|
||||
: `Keine passenden Treffer gefunden (${result.checked} Dienste ohne Favicon geprüft).`
|
||||
);
|
||||
function FaviconSuggestionsPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<FaviconSuggestion[] | null>(null);
|
||||
const [checkedCount, setCheckedCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
setOpen(true);
|
||||
fetch("/api/services/favicon-suggestions")
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
setSuggestions(body.suggestions ?? []);
|
||||
setCheckedCount(body.checked ?? 0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: ({ serviceId, url }: { serviceId: string; url: string }) => patchService(serviceId, { favicon: url }),
|
||||
onSuccess: (_data, { serviceId }) => {
|
||||
setSuggestions((prev) => (prev ? prev.filter((s) => s.serviceId !== serviceId) : prev));
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
onError: () => setMessage("Fehlgeschlagen - ist die Icon-Datenbank erreichbar (Internetzugang)?"),
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={load}
|
||||
title="Für Dienste ohne Favicon passende Treffer aus der Icon-Datenbank vorschlagen"
|
||||
>
|
||||
Fehlende Favicons ergänzen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{message ? <span className="text-xs text-black/40 dark:text-white/40">{message}</span> : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
mutation.mutate();
|
||||
}}
|
||||
disabled={mutation.isPending}
|
||||
title="Für alle Dienste ohne Favicon einen passenden Treffer aus der Icon-Datenbank suchen"
|
||||
>
|
||||
{mutation.isPending ? "Suche …" : "Fehlende Favicons ergänzen"}
|
||||
</Button>
|
||||
<div className="mb-3 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-black/60 dark:text-white/60">
|
||||
{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).`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-black/40 underline dark:text-white/40"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{suggestions && suggestions.length > 0 ? (
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.serviceId} className="flex items-center gap-3">
|
||||
<span className="w-32 shrink-0 truncate text-xs text-black/70 dark:text-white/70">
|
||||
{s.serviceName}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{s.candidates.map((c) => (
|
||||
<button
|
||||
key={c.url}
|
||||
type="button"
|
||||
title={c.name}
|
||||
onClick={() => applyMutation.mutate({ serviceId: s.serviceId, url: c.url })}
|
||||
disabled={applyMutation.isPending}
|
||||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<Favicon src={c.url} fallbackLetter={c.name} size="sm" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSuggestions((prev) => (prev ? prev.filter((x) => x.serviceId !== s.serviceId) : prev))}
|
||||
className="ml-auto shrink-0 text-xs text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
|
||||
title="Überspringen"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -696,9 +758,10 @@ export function ServicesPage() {
|
||||
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
|
||||
</button>
|
||||
</div>
|
||||
<BackfillFaviconsButton />
|
||||
</div>
|
||||
|
||||
<FaviconSuggestionsPanel />
|
||||
|
||||
{sortColumn ? (
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||||
|
||||
Reference in New Issue
Block a user