round21: Startseiten-Bereiche in eine Karte zusammengefasst, Favicon-Auswahl filtert tote Icons und zeigt groessere Vorschau, Icon fuer manuelle Anpassung getauscht, Zeitstempel-Inkonsistenz Scan/Ping behoben

This commit is contained in:
2026-07-23 20:03:34 +02:00
parent 68ca9ec5e1
commit 6a66128172
4 changed files with 147 additions and 47 deletions

View File

@@ -12,6 +12,85 @@ interface IconDbResult {
url: string;
}
function resolvableSrc(src: string): string {
if (src.startsWith("data:")) return src;
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);
});
}
interface NamedIcon {
url: string;
name: string;
}
/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder. */
function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
const [loadable, setLoadable] = useState<NamedIcon[]>([]);
const key = items.map((i) => i.url).join("|");
useEffect(() => {
let cancelled = false;
setLoadable([]);
(async () => {
const results: NamedIcon[] = [];
for (const item of items) {
if (await checkImageLoads(item.url)) results.push(item);
}
if (!cancelled) setLoadable(results);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return loadable;
}
function HoverPreview({ url, name }: { url: string; name: string }) {
return (
<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"
>
<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>
);
}
function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) {
const [hovered, setHovered] = useState(false);
return (
<div className="relative">
{hovered ? <HoverPreview url={url} name={name} /> : null}
<button
type="button"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
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>
);
}
/**
* Ein Button, der einen Picker öffnet, der ZWEI Favicon-Quellen kombiniert:
* bereits im System verwendete Favicons (z. B. von anderen Diensten) und
@@ -19,11 +98,17 @@ interface IconDbResult {
* icons, kuratierte Icons für Selfhosted-Software). Bewusst EIN Button statt
* 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.
*/
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [dbResults, setDbResults] = useState<IconDbResult[]>([]);
const [rawDbResults, setRawDbResults] = useState<IconDbResult[]>([]);
const [searching, setSearching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -39,20 +124,24 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setDbResults([]);
setRawDbResults([]);
return;
}
setSearching(true);
const handle = setTimeout(() => {
fetch(`/api/icons/search?q=${encodeURIComponent(q)}`)
.then((res) => (res.ok ? res.json() : { results: [] }))
.then((body) => setDbResults(body.results ?? []))
.catch(() => setDbResults([]))
.then((body) => setRawDbResults(body.results ?? []))
.catch(() => setRawDbResults([]))
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(handle);
}, [query]);
const existingAsResults = existingFavicons.map(([url, name]) => ({ url, name }));
const loadableExisting = useLoadableOnly(existingAsResults);
const loadableDbResults = useLoadableOnly(rawDbResults);
const showingSearch = query.trim().length >= 2;
return (
@@ -81,50 +170,44 @@ 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-48 overflow-y-auto pt-6">
{showingSearch ? (
searching ? (
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche </p>
) : dbResults.length > 0 ? (
) : loadableDbResults.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{dbResults.map((r) => (
<button
key={r.name}
type="button"
title={r.name}
onClick={() => {
onSelect(r.url);
{loadableDbResults.map((r) => (
<IconTile
key={r.url}
url={r.url}
name={r.name}
onSelect={(url) => {
onSelect(url);
setOpen(false);
setQuery("");
}}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={r.url} fallbackLetter={r.name} size="sm" />
</button>
/>
))}
</div>
) : (
<p className="p-2 text-xs text-black/40 dark:text-white/40">Keine Treffer.</p>
)
) : existingFavicons.length > 0 ? (
) : loadableExisting.length > 0 ? (
<>
<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">
{existingFavicons.map(([iconUrl, name]) => (
<button
key={iconUrl}
type="button"
title={name}
onClick={() => {
onSelect(iconUrl);
{loadableExisting.map((r) => (
<IconTile
key={r.url}
url={r.url}
name={r.name}
onSelect={(url) => {
onSelect(url);
setOpen(false);
}}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={iconUrl} fallbackLetter={name} size="sm" />
</button>
/>
))}
</div>
</>