round22: Startseite kompakter, Ping-Zeitstempel-Bug behoben, echte Favicon-Deduplizierung per Bildinhalt, automatischer Favicon-Abgleich aus Icon-Datenbank + Nachruest-Button

This commit is contained in:
2026-07-23 20:58:58 +02:00
parent 6a66128172
commit b098007742
8 changed files with 181 additions and 49 deletions

View File

@@ -17,15 +17,27 @@ function resolvableSrc(src: string): string {
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
}
/** Prüft per Image-Preload, ob eine Favicon-URL tatsächlich ein Bild liefert
* (nicht nur eine plausibel aussehende, aber tote/404-URL). */
function checkImageLoads(src: string): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = resolvableSrc(src);
});
/**
* Lädt eine Favicon-URL tatsächlich (nicht nur eine Existenzprüfung) und
* bildet einen Inhalts-Hash daraus. Liefert null, wenn das Laden fehlschlägt.
* Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter
* unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die
* alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich
* würde solche Duplikate nicht erkennen.
*/
async function loadAndHash(src: string): Promise<string | null> {
try {
const res = await fetch(resolvableSrc(src));
if (!res.ok) return null;
const buffer = await res.arrayBuffer();
if (buffer.byteLength === 0) return null;
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} catch {
return null;
}
}
interface NamedIcon {
@@ -33,7 +45,8 @@ interface NamedIcon {
name: string;
}
/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder. */
/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder
* und dedupliziert dabei nach Bildinhalt (nicht nur nach URL). */
function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
const [loadable, setLoadable] = useState<NamedIcon[]>([]);
const key = items.map((i) => i.url).join("|");
@@ -42,9 +55,13 @@ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
let cancelled = false;
setLoadable([]);
(async () => {
const seenHashes = new Set<string>();
const results: NamedIcon[] = [];
for (const item of items) {
if (await checkImageLoads(item.url)) results.push(item);
const hash = await loadAndHash(item.url);
if (!hash || seenHashes.has(hash)) continue;
seenHashes.add(hash);
results.push(item);
}
if (!cancelled) setLoadable(results);
})();

View File

@@ -74,11 +74,11 @@ export function FavoritesBar({
return (
<div>
{label ? (
<div className="mb-2.5 text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
{label}
</div>
) : null}
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2.5">
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2">
{list.map((item) => {
const ringColor = item.category ? categoryColors?.[item.category] : undefined;
return (
@@ -95,20 +95,20 @@ export function FavoritesBar({
? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})`
}
className={`group flex w-16 flex-col items-center gap-1.5 rounded-xl p-1.5
className={`group flex w-14 flex-col items-center gap-1 rounded-xl 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" : ""}`}
>
<span
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
className="flex h-11 w-11 items-center justify-center rounded-xl border
className="flex h-9 w-9 items-center justify-center rounded-xl border
border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</span>
<span className="w-full truncate text-center text-[11px] leading-tight text-black/55 dark:text-white/55">
<span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
{item.displayName}
</span>
</button>