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:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Favicon } from "./Favicon.js";
|
||||
|
||||
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
|
||||
* bildet einen Inhalts-Hash daraus. Liefert null, wenn das Laden fehlschlägt.
|
||||
* Lädt eine Favicon-URL tatsächlich, prüft per createImageBitmap, ob es sich
|
||||
* 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
|
||||
* 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.
|
||||
* 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> {
|
||||
try {
|
||||
@@ -31,6 +37,14 @@ async function loadAndHash(src: string): Promise<string | null> {
|
||||
if (!res.ok) return null;
|
||||
const buffer = await res.arrayBuffer();
|
||||
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);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
@@ -74,37 +88,54 @@ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
|
||||
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
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-30 mb-2 w-max max-w-[12rem]
|
||||
-translate-x-1/2 rounded-xl border border-black/10 bg-white p-2 text-center shadow-xl
|
||||
dark:border-white/10 dark:bg-neutral-800"
|
||||
style={{ position: "fixed", left, top, width, transform: "translateY(-100%)" }}
|
||||
className="pointer-events-none z-50 rounded-xl border border-black/10 bg-white p-2 text-center
|
||||
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
|
||||
ring-1 ring-black/5">
|
||||
<img src={resolvableSrc(url)} alt="" className="h-full w-full object-contain" />
|
||||
</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 }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{hovered ? <HoverPreview url={url} name={name} /> : null}
|
||||
<>
|
||||
{anchorRect ? <HoverPreview url={url} name={name} anchorRect={anchorRect} /> : null}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
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"
|
||||
>
|
||||
<Favicon src={url} fallbackLetter={name} size="sm" />
|
||||
</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
|
||||
* weiteren Button überladen werden.
|
||||
*
|
||||
* Beide Listen werden vor der Anzeige per Image-Preload geprüft - nur
|
||||
* tatsächlich ladbare Bilder erscheinen. Eine URL, die nur plausibel aussieht
|
||||
* aber ins Leere läuft (z. B. eine alte, vor diesem Fix gespeicherte
|
||||
* Rate-Adresse), würde sonst als Buchstaben-Fallback auftauchen und die
|
||||
* Auswahl unnötig vollstopfen.
|
||||
* Beide Listen werden vor der Anzeige tatsächlich geladen UND als Bild
|
||||
* dekodiert geprüft (siehe loadAndHash) - nur echte, funktionierende Icons
|
||||
* erscheinen. Eine URL, die nur plausibel aussieht aber ins Leere läuft oder
|
||||
* keine echte Bilddatei liefert, würde sonst als Buchstaben-Fallback
|
||||
* auftauchen und die Auswahl unnötig vollstopfen.
|
||||
*/
|
||||
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
|
||||
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"
|
||||
/>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto pt-6">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{showingSearch ? (
|
||||
searching ? (
|
||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche …</p>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function FavoritesBar({
|
||||
{label}
|
||||
</div>
|
||||
) : 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) => {
|
||||
const ringColor = item.category ? categoryColors?.[item.category] : undefined;
|
||||
return (
|
||||
@@ -95,14 +95,14 @@ export function FavoritesBar({
|
||||
? `${item.displayName} (${item.hostname}:${item.port})`
|
||||
: `${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] ${
|
||||
onReorder ? "cursor-grab active:cursor-grabbing" : ""
|
||||
} ${draggedId === item.id ? "opacity-40" : ""}`}
|
||||
>
|
||||
<span
|
||||
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
|
||||
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user