generated from Dicken/dickendock
round24: Favicon-Groesse und Wort-Erkennung verbessert, ausgeblendet-Badge entfernt, Proxmox-Port-Bug gefixt, Suche um Geraete und Spaeter-lesen erweitert
This commit is contained in:
@@ -81,19 +81,35 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett
|
||||
* ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein
|
||||
* sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht
|
||||
* nach "LXC").
|
||||
*/
|
||||
const STOPWORDS = new Set([
|
||||
"lxc", "vm", "docker", "container", "server", "service", "app", "box",
|
||||
"host", "node", "instance", "srv", "ct", "the", "der", "die", "das",
|
||||
"lxc", "vm", "docker", "container", "srv", "ct", "instance",
|
||||
"the", "der", "die", "das",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES
|
||||
* Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen,
|
||||
* nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die
|
||||
* Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg
|
||||
* versucht, nachdem alle spezifischeren Wörter erfolglos blieben.
|
||||
*/
|
||||
const GENERIC_WORDS = new Set([
|
||||
"cam", "camera", "media", "print", "printer", "bridge", "hub", "sync",
|
||||
"box", "sensor", "plug", "light", "dock", "home", "server", "service",
|
||||
"app", "web", "tv", "cast", "stream", "monitor", "gateway", "node",
|
||||
"backup", "storage", "network", "device", "system",
|
||||
]);
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */
|
||||
function significantWords(name: string): string[] {
|
||||
return name
|
||||
.split(/[\s_-]+/)
|
||||
@@ -101,33 +117,58 @@ function significantWords(name: string): string[] {
|
||||
.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;
|
||||
/** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */
|
||||
function bigrams(words: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */
|
||||
function byPriority(words: string[]): string[] {
|
||||
const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||
const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||
return [...specific, ...generic];
|
||||
}
|
||||
|
||||
function exactMatch(candidate: string, names: string[]): string | null {
|
||||
if (names.includes(candidate)) return candidate;
|
||||
const collapsed = candidate.replace(/-/g, "");
|
||||
const found = names.find((n) => n.replace(/-/g, "") === collapsed);
|
||||
return found ?? null;
|
||||
return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
|
||||
* 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.
|
||||
* Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - 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 und ausschließlich über exakte (bzw. nach
|
||||
* Entfernen von Bindestrichen exakte) Übereinstimmungen - keine
|
||||
* Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte
|
||||
* Zuordnung zu unzuverlässig.
|
||||
*
|
||||
* Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt
|
||||
* "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere,
|
||||
* nicht-generische) zuerst.
|
||||
*/
|
||||
export async function findBestIconMatch(name: string): Promise<string | null> {
|
||||
const names = await getIconNames();
|
||||
|
||||
const wholeName = normalize(name);
|
||||
if (wholeName.length >= 2) {
|
||||
const match = await exactMatch(wholeName, names);
|
||||
const match = exactMatch(wholeName, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
for (const word of significantWords(name)) {
|
||||
const match = await exactMatch(word, names);
|
||||
const words = significantWords(name);
|
||||
|
||||
for (const pair of bigrams(words)) {
|
||||
const match = exactMatch(pair, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
for (const word of byPriority(words)) {
|
||||
const match = exactMatch(word, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
@@ -135,11 +176,17 @@ export async function findBestIconMatch(name: string): Promise<string | 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.
|
||||
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur
|
||||
* den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht,
|
||||
* wo der Mensch selbst auswählt, welcher Treffer stimmt.
|
||||
*
|
||||
* Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne
|
||||
* Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist -
|
||||
* Teilstring-Treffer, wieder erst über spezifische Wörter. Generische
|
||||
* Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur
|
||||
* herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben -
|
||||
* genau das war das gemeldete Problem ("Cam Blink Sync" fand nur
|
||||
* Kamera-Zubehör über "Cam" statt "Blink").
|
||||
*/
|
||||
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
|
||||
const names = await getIconNames();
|
||||
@@ -147,20 +194,31 @@ export async function suggestIconsForServiceName(name: string, limit = 8): Promi
|
||||
const results: IconSearchResult[] = [];
|
||||
|
||||
function addIfNew(iconName: string) {
|
||||
if (seen.has(iconName)) return;
|
||||
if (seen.has(iconName) || results.length >= limit) return;
|
||||
seen.add(iconName);
|
||||
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
|
||||
}
|
||||
|
||||
const words = significantWords(name);
|
||||
const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w));
|
||||
const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w));
|
||||
|
||||
const wholeName = normalize(name);
|
||||
if (wholeName.length >= 2) {
|
||||
const exact = await exactMatch(wholeName, names);
|
||||
const exact = exactMatch(wholeName, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const pair of bigrams(words)) {
|
||||
const exact = exactMatch(pair, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const word of specificWords) {
|
||||
const exact = exactMatch(word, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
|
||||
for (const word of significantWords(name)) {
|
||||
const exact = await exactMatch(word, names);
|
||||
if (exact) addIfNew(exact);
|
||||
// Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist.
|
||||
for (const word of specificWords) {
|
||||
if (results.length >= limit) break;
|
||||
for (const n of names) {
|
||||
if (results.length >= limit) break;
|
||||
@@ -168,5 +226,21 @@ export async function suggestIconsForServiceName(name: string, limit = 8): Promi
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
// Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg,
|
||||
// wenn spezifische Wörter überhaupt nichts geliefert haben.
|
||||
if (results.length === 0) {
|
||||
for (const word of genericWords) {
|
||||
const exact = exactMatch(word, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const word of genericWords) {
|
||||
if (results.length >= limit) break;
|
||||
for (const n of names) {
|
||||
if (results.length >= limit) break;
|
||||
if (n.includes(word)) addIfNew(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -97,9 +97,23 @@ export async function scanDeviceServices(
|
||||
const open = await isPortOpen(device.ip, port);
|
||||
if (!open) continue;
|
||||
|
||||
const isHttps = port === 443 || port === 9443;
|
||||
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||
const probe = await probeHttp(baseUrl);
|
||||
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
|
||||
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||
let probe = await probeHttp(baseUrl);
|
||||
|
||||
// probe.status === undefined heißt: die Verbindung ist auf HTTP-Ebene
|
||||
// gescheitert (z. B. TLS-Handshake-Fehler bei einem HTTPS-only-Dienst wie
|
||||
// Proxmox auf Port 8006, den die Ports-Liste oben nicht kennt) - nicht
|
||||
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
|
||||
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
|
||||
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
|
||||
// der zusätzliche Versuch.
|
||||
if (probe.status === undefined) {
|
||||
isHttps = !isHttps;
|
||||
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||
const retryProbe = await probeHttp(baseUrl);
|
||||
if (retryProbe.status !== undefined) probe = retryProbe;
|
||||
}
|
||||
|
||||
const software = detectSoftware({
|
||||
server: probe.server,
|
||||
|
||||
@@ -3,7 +3,18 @@ import { connect } from "node:net";
|
||||
/**
|
||||
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
|
||||
*/
|
||||
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443];
|
||||
export const TYPICAL_PORTS = [
|
||||
80, 443, 81, 8000, 8080, 8081, 8443, 8888,
|
||||
3000, 3001, 5000, 5001,
|
||||
8006, // Proxmox VE
|
||||
9000, 9090, 9091, 9443, // Portainer, Cockpit/Prometheus, Transmission
|
||||
8123, // Home Assistant
|
||||
32400, // Plex
|
||||
7878, 8989, 9117, 6789, // Radarr, Sonarr, Jackett/Prowlarr, NZBGet/SABnzbd
|
||||
19999, // Netdata
|
||||
5665, 5666, // Icinga/Nagios
|
||||
8181, 8282,
|
||||
];
|
||||
|
||||
/**
|
||||
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { rankServices, type SearchResult } from "@launchpad/shared";
|
||||
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";
|
||||
@@ -133,8 +133,9 @@ export function HomePage() {
|
||||
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 && item.id) {
|
||||
if ("kind" in item && (item.kind === "service" || item.kind === "bookmark") && item.id) {
|
||||
recordVisit(item.kind, item.id).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["recent-visits"] });
|
||||
});
|
||||
@@ -167,24 +168,57 @@ export function HomePage() {
|
||||
});
|
||||
|
||||
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
|
||||
// siehe Admin -> Dienste) tauchen in der Suche nicht auf.
|
||||
// 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,
|
||||
}));
|
||||
return [...visibleServices, ...bookmarkItems];
|
||||
}, [services, bookmarks, devices]);
|
||||
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(() => rankServices(allItems, query), [allItems, query]);
|
||||
const results = useMemo(() => rankSearchResults(allItems, query), [allItems, query]);
|
||||
|
||||
const favoriteServices = useMemo(
|
||||
() =>
|
||||
@@ -390,7 +424,7 @@ export function HomePage() {
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
</span>
|
||||
<span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
|
||||
<span className="line-clamp-2 w-full text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
|
||||
{item.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -402,11 +402,6 @@ function ServiceRow({
|
||||
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
|
||||
</span>
|
||||
) : null}
|
||||
{!service.visible ? (
|
||||
<span className="rounded-full bg-black/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/50 dark:bg-white/10 dark:text-white/50">
|
||||
ausgeblendet
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -586,7 +581,7 @@ function FaviconSuggestionsPanel() {
|
||||
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" />
|
||||
<Favicon src={c.url} fallbackLetter={c.name} size="lg" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface Service {
|
||||
* keine DNS-Auflösung möglich war.
|
||||
*/
|
||||
deviceHostname?: string | null;
|
||||
/**
|
||||
* Ebenfalls nur frontend-seitig ergänzt - Online-Status des zugehörigen
|
||||
* Geräts, für den kleinen Online/Offline-Punkt in der Trefferliste.
|
||||
*/
|
||||
deviceOnline?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
@@ -125,13 +130,53 @@ export interface Rankable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste und
|
||||
* Lesezeichen zusammen, aber unterscheidbar über `kind` (siehe
|
||||
* "getrennt von Diensten" bei Favoriten).
|
||||
* Synthetische Rankable-Form für Geräte in der Suche - Geräte haben keine
|
||||
* eigene URL zum Öffnen (siehe DeviceSearchResult.kind==="device" in
|
||||
* ResultsList: wird ausgegraut und nicht anklickbar dargestellt), sollen
|
||||
* aber trotzdem über Name/IP/MAC auffindbar sein (IP/MAC landen in `alias`,
|
||||
* dort durchsucht rankService bereits).
|
||||
*/
|
||||
export interface DeviceSearchResult {
|
||||
kind: "device";
|
||||
id: string;
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
online: boolean;
|
||||
favicon: null;
|
||||
category: null;
|
||||
favorite: false;
|
||||
order: number;
|
||||
alias: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** Synthetische Rankable-Form für Später-lesen-Einträge in der Suche. */
|
||||
export interface ReadLaterSearchResult {
|
||||
kind: "readlater";
|
||||
id: string;
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
url: string;
|
||||
favicon: string | null;
|
||||
category: null;
|
||||
favorite: false;
|
||||
order: number;
|
||||
alias: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste,
|
||||
* Lesezeichen, Geräte und Später-lesen-Einträge zusammen, aber
|
||||
* unterscheidbar über `kind`.
|
||||
*/
|
||||
export type SearchResult =
|
||||
| (Service & { kind: "service" })
|
||||
| (Bookmark & { kind: "bookmark" });
|
||||
| (Bookmark & { kind: "bookmark" })
|
||||
| DeviceSearchResult
|
||||
| ReadLaterSearchResult;
|
||||
|
||||
/**
|
||||
* Ranking-Stufen für die Suche, gemäß Spezifikation:
|
||||
@@ -194,6 +239,37 @@ export function rankServices<T extends Rankable>(items: T[], query: string): T[]
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie rankServices, aber für die Startseiten-Suche, die mehrere Arten von
|
||||
* Treffern mischt (Dienste, Lesezeichen, Später-lesen, Geräte). Dienste
|
||||
* stehen dabei IMMER vor allen anderen Arten, unabhängig von der
|
||||
* Treffergüte - sie sind die eigentlichen "Ziele" der App. Geräte stehen
|
||||
* immer zuletzt (sie sind nur informativ, nicht direkt anklickbar, siehe
|
||||
* DeviceSearchResult). Lesezeichen/Später-lesen liegen dazwischen.
|
||||
*/
|
||||
export function rankSearchResults(items: SearchResult[], query: string): SearchResult[] {
|
||||
const q = query.trim();
|
||||
if (q.length === 0) return [];
|
||||
|
||||
function kindPriority(kind: SearchResult["kind"]): number {
|
||||
if (kind === "service") return 0;
|
||||
if (kind === "device") return 2;
|
||||
return 1; // bookmark, readlater
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => ({ item, rank: rankService(item, q) }))
|
||||
.filter((entry): entry is { item: SearchResult; rank: number } => entry.rank !== null)
|
||||
.sort((a, b) => {
|
||||
const kindDiff = kindPriority(a.item.kind) - kindPriority(b.item.kind);
|
||||
if (kindDiff !== 0) return kindDiff;
|
||||
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||
if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
|
||||
return a.item.order - b.item.order;
|
||||
})
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bekannte Domain-Fragmente -> naheliegende Kategorie, für den Kategorie-
|
||||
* Vorschlag beim Anlegen eines Lesezeichens (siehe BookmarksPage.tsx). Rein
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useState, useEffect } from "react";
|
||||
export interface FaviconProps {
|
||||
src?: string | null;
|
||||
fallbackLetter: string;
|
||||
size?: "sm" | "md";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, string> = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-7 w-7",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,9 +131,9 @@ function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect
|
||||
onMouseEnter={() => setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null)}
|
||||
onMouseLeave={() => setAnchorRect(null)}
|
||||
onClick={() => onSelect(url)}
|
||||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
className="rounded-lg p-1 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<Favicon src={url} fallbackLetter={name} size="sm" />
|
||||
<Favicon src={url} fallbackLetter={name} size="lg" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@@ -205,7 +205,7 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className="absolute left-0 top-full z-20 mt-1 w-72 rounded-xl border border-black/10
|
||||
className="absolute left-0 top-full z-20 mt-1 w-80 rounded-xl border border-black/10
|
||||
bg-white p-2 shadow-lg dark:border-white/10 dark:bg-neutral-900"
|
||||
>
|
||||
<input
|
||||
@@ -218,12 +218,12 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30"
|
||||
/>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{showingSearch ? (
|
||||
searching ? (
|
||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche …</p>
|
||||
) : loadableDbResults.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loadableDbResults.map((r) => (
|
||||
<IconTile
|
||||
key={r.url}
|
||||
@@ -245,7 +245,7 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps
|
||||
<p className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||
Bereits im System verwendet
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loadableExisting.map((r) => (
|
||||
<IconTile
|
||||
key={r.url}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function FavoritesBar({
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
</span>
|
||||
<span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
|
||||
<span className="line-clamp-2 w-full text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
|
||||
{item.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,11 +26,31 @@ export interface ResultsListProps {
|
||||
onToggleFavorite?: (item: SearchResult) => void;
|
||||
}
|
||||
|
||||
function OnlineDot({ online }: { online: boolean }) {
|
||||
return (
|
||||
<span
|
||||
title={online ? "Online" : "Offline"}
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_BADGE: Record<Exclude<SearchResult["kind"], "service">, string> = {
|
||||
bookmark: "Lesezeichen",
|
||||
readlater: "Später lesen",
|
||||
device: "Gerät",
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an – Dienste
|
||||
* und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die
|
||||
* Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert;
|
||||
* diese Komponente ist rein darstellend + klick-/tastaturbar.
|
||||
* Zeigt die (bereits per rankSearchResults sortierten) Suchtreffer an –
|
||||
* Dienste, Lesezeichen, Später-lesen und Geräte gemeinsam, unterscheidbar an
|
||||
* einem kleinen Badge. Geräte haben keine eigene URL zum Öffnen und werden
|
||||
* deshalb ausgegraut und nicht anklickbar dargestellt (nur zum Auffinden per
|
||||
* Name/IP/MAC, siehe rankSearchResults) - Dienste und Geräte zeigen
|
||||
* zusätzlich einen Online/Offline-Punkt.
|
||||
*
|
||||
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
|
||||
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar.
|
||||
*
|
||||
* Füllt die Höhe des Elternelements aus (h-full) und scrollt selbst intern –
|
||||
* das Elternelement muss dafür `flex-1 min-h-0` sein (klassischer
|
||||
@@ -64,43 +84,53 @@ export function ResultsList({
|
||||
>
|
||||
{results.map((item, index) => {
|
||||
const active = index === selectedIndex;
|
||||
const isDevice = item.kind === "device";
|
||||
const subtitle =
|
||||
item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname;
|
||||
item.kind === "service"
|
||||
? `${item.hostname}:${item.port}`
|
||||
: item.kind === "device"
|
||||
? item.ip
|
||||
: item.hostname;
|
||||
const categoryColor = item.category ? categoryColors?.[item.category] : undefined;
|
||||
const online = item.kind === "device" ? item.online : item.kind === "service" ? item.deviceOnline : undefined;
|
||||
|
||||
return (
|
||||
<li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onClick={() => onOpen(item)}
|
||||
onClick={() => !isDevice && onOpen(item)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter") onOpen(item);
|
||||
if (e.key === "Enter" && !isDevice) onOpen(item);
|
||||
}}
|
||||
style={
|
||||
categoryColor
|
||||
categoryColor && !isDevice
|
||||
? { backgroundColor: hexToRgba(categoryColor, active ? 0.18 : 0.1) ?? undefined }
|
||||
: undefined
|
||||
}
|
||||
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
|
||||
transition-colors ${
|
||||
className={`flex w-full items-center gap-3 px-5 py-3 text-left transition-colors ${
|
||||
isDevice
|
||||
? "cursor-default opacity-50"
|
||||
: `cursor-pointer ${
|
||||
categoryColor
|
||||
? ""
|
||||
: active
|
||||
? "bg-black/5 dark:bg-white/10"
|
||||
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
||||
}`
|
||||
}`}
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{online !== undefined ? <OnlineDot online={online} /> : null}
|
||||
<span className="block truncate text-sm font-medium text-black dark:text-white">
|
||||
{item.displayName}
|
||||
</span>
|
||||
{item.kind === "bookmark" ? (
|
||||
{item.kind !== "service" ? (
|
||||
<span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/40 dark:bg-white/10 dark:text-white/40">
|
||||
Lesezeichen
|
||||
{KIND_BADGE[item.kind]}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -122,8 +152,9 @@ export function ResultsList({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<CopyButton url={item.url} />
|
||||
{!isDevice ? <CopyButton url={item.url} /> : null}
|
||||
|
||||
{item.kind === "service" || item.kind === "bookmark" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -141,6 +172,7 @@ export function ResultsList({
|
||||
>
|
||||
<FontAwesomeIcon icon={item.favorite ? faStarSolid : faStar} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user