generated from Dicken/dickendock
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:
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user