round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege

This commit is contained in:
2026-07-24 00:26:08 +02:00
parent b098007742
commit d87885f16f
7 changed files with 252 additions and 108 deletions

View File

@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import { findBestIconMatch } from "../scanner/iconDb.js"; import { suggestIconsForServiceName } from "../scanner/iconDb.js";
interface ServiceListQuery { interface ServiceListQuery {
deviceId?: string; deviceId?: string;
@@ -75,23 +75,19 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(204).send(); return reply.code(204).send();
}); });
// Nachträgliches Ergänzen fehlender Favicons für bereits bestehende // Liefert für jeden Dienst OHNE Favicon eine Liste möglicher Treffer aus
// Dienste (z. B. von vor diesem Feature) - versucht für jeden Dienst OHNE // der externen Icon-Datenbank (siehe scanner/iconDb.ts), damit der Nutzer
// Favicon einen passenden Treffer in der externen Icon-Datenbank zu // selbst auswählen kann statt dass automatisch geraten wird (mehrdeutige
// finden (siehe scanner/iconDb.ts), anhand des aktuellen Anzeigenamens. // Namen wie "LXC Adguard" könnten sonst falsch zugeordnet werden).
// Rein additiv: bestehende Favicons werden nie überschrieben, nur leere app.get("/api/services/favicon-suggestions", async () => {
// Felder aufgefüllt. const services = serviceRepo.listServices().filter((s) => !s.favicon);
app.post("/api/services/backfill-favicons", async () => { const suggestions = [];
const services = serviceRepo.listServices();
let updated = 0;
for (const service of services) { for (const service of services) {
if (service.favicon) continue; const candidates = await suggestIconsForServiceName(service.displayName);
const match = await findBestIconMatch(service.displayName); if (candidates.length > 0) {
if (match) { suggestions.push({ serviceId: service.id, serviceName: service.displayName, candidates });
serviceRepo.updateService(service.id, { favicon: match });
updated++;
} }
} }
return { checked: services.length, updated }; return { checked: services.length, suggestions };
}); });
} }

View File

@@ -80,31 +80,93 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` })); 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-/ * Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
* Software-Namen ("Plex", "Pi-hole", "Adguard Home") - für den Fall, dass * Software-Namen ("Plex", "LXC Adguard", "Pi-hole") - für den Fall, dass der
* der Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts). * Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts). Läuft
* Bewusst konservativ: nur ein Treffer, wenn der normalisierte Name exakt * unbeaufsichtigt während eines Scans, liefert deshalb nur EINEN Treffer
* (oder nach Entfernen von Bindestrichen) übereinstimmt - eine reine * (den ersten gefundenen: erst der ganze Name, dann Wort für Wort, generische
* Teilstring-Suche würde bei kurzen/generischen Namen zu viele falsche * Begriffe wie "LXC"/"Docker" werden dabei übersprungen) statt einer Auswahl.
* Zuordnungen liefern (z. B. "nas" -> "nas4free" wäre noch plausibel, aber
* "web" könnte alles Mögliche treffen).
*/ */
export async function findBestIconMatch(name: string): Promise<string | null> { 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 names = await getIconNames();
const nameSet = new Set(names);
if (nameSet.has(normalized)) return `${CDN_BASE}/${normalized}.png`;
const collapsed = normalized.replace(/-/g, ""); const wholeName = normalize(name);
const exactCollapsed = names.find((n) => n.replace(/-/g, "") === collapsed); if (wholeName.length >= 2) {
if (exactCollapsed) return `${CDN_BASE}/${exactCollapsed}.png`; 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; 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);
}

View File

@@ -3,14 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js"; import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js"; import { useBookmarks } from "../hooks/useBookmarks.js";
import { useDevices } from "../hooks/useDevices.js"; import { useDevices } from "../hooks/useDevices.js";
import { useSettings } from "../hooks/useSettings.js"; import { useSettings } from "../hooks/useSettings.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.js";
import { useCategories } from "../hooks/useCategories.js"; import { useCategories } from "../hooks/useCategories.js";
import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js"; import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js";
import { useReadLater } from "../hooks/useReadLater.js"; import { useReadLater } from "../hooks/useReadLater.js";
@@ -116,7 +115,6 @@ function ReadLaterBox() {
} }
export function HomePage() { export function HomePage() {
const [theme, toggleTheme] = useTheme();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [resultsVisible, setResultsVisible] = useState(false); const [resultsVisible, setResultsVisible] = useState(false);
@@ -286,14 +284,6 @@ export function HomePage() {
> >
<FontAwesomeIcon icon={faGear} /> <FontAwesomeIcon icon={faGear} />
</Link> </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> </div>
{/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht. {/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht.
@@ -334,7 +324,7 @@ export function HomePage() {
dark:bg-white/[0.02]" dark:bg-white/[0.02]"
> >
{hasFavorites ? ( {hasFavorites ? (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-2 p-2.5">
{favoriteServices.length > 0 ? ( {favoriteServices.length > 0 ? (
<FavoritesBar <FavoritesBar
items={favoriteServices} items={favoriteServices}
@@ -363,7 +353,7 @@ export function HomePage() {
) : null} ) : null}
{recentVisits && recentVisits.length > 0 ? ( {recentVisits && recentVisits.length > 0 ? (
<div className="p-3"> <div className="p-2.5">
<FavoritesBar <FavoritesBar
items={recentVisits} items={recentVisits}
label="Zuletzt besucht" label="Zuletzt besucht"
@@ -376,7 +366,7 @@ export function HomePage() {
</div> </div>
) : null} ) : null}
<div className="p-3"> <div className="p-2.5">
<div className="mb-2.5 flex items-center justify-between gap-2"> <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"> <span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
Später lesen Später lesen
@@ -384,17 +374,17 @@ export function HomePage() {
<ReadLaterBox /> <ReadLaterBox />
</div> </div>
{hasReadLaterChips ? ( {hasReadLaterChips ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{readLaterItems!.slice(0, readLaterLimit).map((item) => ( {readLaterItems!.slice(0, readLaterLimit).map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")} onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
title={item.displayName} 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]" transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
> >
<span <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 border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5" group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
> >

View File

@@ -22,6 +22,7 @@ interface ServicePatch {
port?: number; port?: number;
https?: boolean; https?: boolean;
url?: string; url?: string;
favicon?: string | null;
} }
async function patchService(id: string, patch: ServicePatch): Promise<Service> { async function patchService(id: string, patch: ServicePatch): Promise<Service> {
@@ -503,42 +504,103 @@ function SortableHeader({
); );
} }
function BackfillFaviconsButton() { interface FaviconSuggestion {
const queryClient = useQueryClient(); serviceId: string;
const [message, setMessage] = useState<string | null>(null); serviceName: string;
candidates: { name: string; url: string }[];
}
const mutation = useMutation({ function FaviconSuggestionsPanel() {
mutationFn: async () => { const queryClient = useQueryClient();
const res = await fetch("/api/services/backfill-favicons", { method: "POST" }); const [open, setOpen] = useState(false);
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`); const [suggestions, setSuggestions] = useState<FaviconSuggestion[] | null>(null);
return res.json() as Promise<{ checked: number; updated: number }>; const [checkedCount, setCheckedCount] = useState(0);
}, const [loading, setLoading] = useState(false);
onSuccess: (result) => {
setMessage( function load() {
result.updated > 0 setLoading(true);
? `${result.updated} von ${result.checked} Diensten mit einem passenden Favicon aus der Icon-Datenbank ergänzt.` setOpen(true);
: `Keine passenden Treffer gefunden (${result.checked} Dienste ohne Favicon geprüft).` 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"] }); 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 ( return (
<div className="flex items-center gap-2 pb-2"> <div className="mb-3 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
{message ? <span className="text-xs text-black/40 dark:text-white/40">{message}</span> : null} <div className="mb-2 flex items-center justify-between">
<Button <span className="text-xs font-medium text-black/60 dark:text-white/60">
size="sm" {loading
variant="ghost" ? "Suche …"
onClick={() => { : suggestions && suggestions.length > 0
setMessage(null); ? `Vorschläge für ${suggestions.length} von ${checkedCount} Diensten ohne Favicon:`
mutation.mutate(); : `Keine Vorschläge gefunden (${checkedCount} Dienste ohne Favicon geprüft).`}
}} </span>
disabled={mutation.isPending} <button
title="Für alle Dienste ohne Favicon einen passenden Treffer aus der Icon-Datenbank suchen" onClick={() => setOpen(false)}
> className="text-xs text-black/40 underline dark:text-white/40"
{mutation.isPending ? "Suche …" : "Fehlende Favicons ergänzen"} >
</Button> 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> </div>
); );
} }
@@ -696,9 +758,10 @@ export function ServicesPage() {
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount}) <FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button> </button>
</div> </div>
<BackfillFaviconsButton />
</div> </div>
<FaviconSuggestionsPanel />
{sortColumn ? ( {sortColumn ? (
<div className="mb-3"> <div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}> <Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>

View File

@@ -15,10 +15,12 @@
"@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0", "@fortawesome/react-fontawesome": "^3.5.0",
"@launchpad/shared": "workspace:*", "@launchpad/shared": "workspace:*",
"react": "^18.3.1" "react": "^18.3.1",
"react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Favicon } from "./Favicon.js"; import { Favicon } from "./Favicon.js";
export interface FaviconPickerProps { export interface FaviconPickerProps {
@@ -18,12 +19,17 @@ function resolvableSrc(src: string): string {
} }
/** /**
* Lädt eine Favicon-URL tatsächlich (nicht nur eine Existenzprüfung) und * Lädt eine Favicon-URL tatsächlich, prüft per createImageBitmap, ob es sich
* bildet einen Inhalts-Hash daraus. Liefert null, wenn das Laden fehlschlägt. * WIRKLICH um ein dekodierbares Bild handelt (nicht nur einen HTTP-200 mit
* z. B. einer HTML-Fehlerseite als Inhalt), und bildet bei Erfolg einen
* Inhalts-Hash. Liefert null, wenn das Laden oder Dekodieren fehlschlägt.
* Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter * Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter
* unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die * unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die
* alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich * alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich
* würde solche Duplikate nicht erkennen. * würde solche Duplikate nicht erkennen. Ein bloßer fetch-Erfolg reicht
* NICHT als Bild-Bestätigung: manche Geräte antworten mit HTTP 200 und einer
* Login-/Fehlerseite statt eines echten Bildes, was sonst als Treffer
* durchgerutscht wäre und im Browser als Buchstaben-Fallback endete.
*/ */
async function loadAndHash(src: string): Promise<string | null> { async function loadAndHash(src: string): Promise<string | null> {
try { try {
@@ -31,6 +37,14 @@ async function loadAndHash(src: string): Promise<string | null> {
if (!res.ok) return null; if (!res.ok) return null;
const buffer = await res.arrayBuffer(); const buffer = await res.arrayBuffer();
if (buffer.byteLength === 0) return null; if (buffer.byteLength === 0) return null;
try {
const bitmap = await createImageBitmap(new Blob([buffer]));
bitmap.close();
} catch {
return null; // kein echtes, dekodierbares Bild
}
const digest = await crypto.subtle.digest("SHA-256", buffer); const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest)) return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
@@ -74,37 +88,54 @@ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
return loadable; return loadable;
} }
function HoverPreview({ url, name }: { url: string; name: string }) { /**
return ( * Größere Bild-Vorschau beim Hover, über ein Portal direkt in document.body
* gerendert (nicht innerhalb des scrollenden Picker-Containers) - sonst
* würde sie vom umgebenden overflow-y-auto abgeschnitten, sobald die
* gehoverte Kachel nah am oberen/unteren Rand der Liste liegt.
*/
function HoverPreview({ url, name, anchorRect }: { url: string; name: string; anchorRect: DOMRect }) {
const width = 176;
const left = Math.min(
Math.max(anchorRect.left + anchorRect.width / 2 - width / 2, 8),
window.innerWidth - width - 8
);
const top = anchorRect.top - 8;
return createPortal(
<div <div
className="pointer-events-none absolute bottom-full left-1/2 z-30 mb-2 w-max max-w-[12rem] style={{ position: "fixed", left, top, width, transform: "translateY(-100%)" }}
-translate-x-1/2 rounded-xl border border-black/10 bg-white p-2 text-center shadow-xl className="pointer-events-none z-50 rounded-xl border border-black/10 bg-white p-2 text-center
dark:border-white/10 dark:bg-neutral-800" shadow-xl dark:border-white/10 dark:bg-neutral-800"
> >
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg bg-white p-1 <span className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg bg-white p-1
ring-1 ring-black/5"> ring-1 ring-black/5">
<img src={resolvableSrc(url)} alt="" className="h-full w-full object-contain" /> <img src={resolvableSrc(url)} alt="" className="h-full w-full object-contain" />
</span> </span>
<span className="mt-1 block truncate text-xs text-black/70 dark:text-white/70">{name}</span> <span className="mt-1 block truncate text-xs text-black/70 dark:text-white/70">{name}</span>
</div> </div>,
document.body
); );
} }
function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) { function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) {
const [hovered, setHovered] = useState(false); const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
return ( return (
<div className="relative"> <>
{hovered ? <HoverPreview url={url} name={name} /> : null} {anchorRect ? <HoverPreview url={url} name={name} anchorRect={anchorRect} /> : null}
<button <button
ref={buttonRef}
type="button" type="button"
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setAnchorRect(null)}
onClick={() => onSelect(url)} onClick={() => onSelect(url)}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10" className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
> >
<Favicon src={url} fallbackLetter={name} size="sm" /> <Favicon src={url} fallbackLetter={name} size="sm" />
</button> </button>
</div> </>
); );
} }
@@ -116,11 +147,11 @@ function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect
* zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem * zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem
* weiteren Button überladen werden. * weiteren Button überladen werden.
* *
* Beide Listen werden vor der Anzeige per Image-Preload geprüft - nur * Beide Listen werden vor der Anzeige tatsächlich geladen UND als Bild
* tatsächlich ladbare Bilder erscheinen. Eine URL, die nur plausibel aussieht * dekodiert geprüft (siehe loadAndHash) - nur echte, funktionierende Icons
* aber ins Leere läuft (z. B. eine alte, vor diesem Fix gespeicherte * erscheinen. Eine URL, die nur plausibel aussieht aber ins Leere läuft oder
* Rate-Adresse), würde sonst als Buchstaben-Fallback auftauchen und die * keine echte Bilddatei liefert, würde sonst als Buchstaben-Fallback
* Auswahl unnötig vollstopfen. * auftauchen und die Auswahl unnötig vollstopfen.
*/ */
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) { export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
const [open, setOpen] = useState(false); 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" dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30"
/> />
<div className="max-h-48 overflow-y-auto pt-6"> <div className="max-h-48 overflow-y-auto">
{showingSearch ? ( {showingSearch ? (
searching ? ( searching ? (
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche </p> <p className="p-2 text-xs text-black/40 dark:text-white/40">Suche </p>

View File

@@ -78,7 +78,7 @@ export function FavoritesBar({
{label} {label}
</div> </div>
) : null} ) : null}
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2"> <div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-1.5">
{list.map((item) => { {list.map((item) => {
const ringColor = item.category ? categoryColors?.[item.category] : undefined; const ringColor = item.category ? categoryColors?.[item.category] : undefined;
return ( return (
@@ -95,14 +95,14 @@ export function FavoritesBar({
? `${item.displayName} (${item.hostname}:${item.port})` ? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})` : `${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] ${ transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${
onReorder ? "cursor-grab active:cursor-grabbing" : "" onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`} } ${draggedId === item.id ? "opacity-40" : ""}`}
> >
<span <span
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined} style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
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 border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5" group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
> >