From 6a661281727a686575d6f1ca5e7bf985a27a5d2d Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 23 Jul 2026 20:03:34 +0200 Subject: [PATCH] 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 --- apps/frontend/src/routes/HomePage.tsx | 30 ++-- .../frontend/src/routes/admin/DevicesPage.tsx | 17 ++- .../src/routes/admin/ServicesPage.tsx | 6 +- packages/ui/src/FaviconPicker.tsx | 141 ++++++++++++++---- 4 files changed, 147 insertions(+), 47 deletions(-) diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index df59158..323e199 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -328,9 +328,13 @@ export function HomePage() { {showShelf ? ( -
+
{hasFavorites ? ( -
+
{favoriteServices.length > 0 ? ( 0 ? ( - { - const original = recentVisits.find((r) => r.id === item.id); - if (original) openItem(original); - }} - /> +
+ { + const original = recentVisits.find((r) => r.id === item.id); + if (original) openItem(original); + }} + /> +
) : null} -
+
Später lesen diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 9463f12..0c72e30 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -382,9 +382,20 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { {device.online ? "Online" : "Offline"}
- {device.lastScan - ? `zuletzt gesehen: ${new Date(device.lastScan).toLocaleString("de-DE")}` - : "nie gescannt"} + {(() => { + // "Zuletzt gesehen" soll den neueren der beiden Zeitpunkte + // zeigen - ein echter Scan (lastScan) oder ein reiner + // Ping-Live-Status-Check (lastPing, siehe Einstellungen) - + // sonst wirkt es inkonsistent, wenn der Online-Punkt frisch + // vom Ping aktualisiert wurde, der Zeitstempel aber noch den + // alten Scan zeigt. + const scanTime = device.lastScan ? new Date(device.lastScan).getTime() : 0; + const pingTime = device.lastPing ? new Date(device.lastPing).getTime() : 0; + const latest = Math.max(scanTime, pingTime); + if (latest === 0) return "nie gescannt"; + const label = pingTime > scanTime ? "zuletzt gesehen (Ping)" : "zuletzt gesehen"; + return `${label}: ${new Date(latest).toLocaleString("de-DE")}`; + })()}
{device.services.length} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 3778404..90ad102 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type DragEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft, faSignature } from "@fortawesome/free-solid-svg-icons"; +import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft, faUserPen } from "@fortawesome/free-solid-svg-icons"; import { faStar } from "@fortawesome/free-regular-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon, FaviconPicker } from "@launchpad/ui"; @@ -398,7 +398,7 @@ function ServiceRow({ {service.displayName} {service.displayNameEditedManually ? ( - + ) : null} {!service.visible ? ( @@ -429,7 +429,7 @@ function ServiceRow({ {service.category ?? "–"} {service.categoryEditedManually ? ( - + ) : null} diff --git a/packages/ui/src/FaviconPicker.tsx b/packages/ui/src/FaviconPicker.tsx index eb3b73b..89cb11e 100644 --- a/packages/ui/src/FaviconPicker.tsx +++ b/packages/ui/src/FaviconPicker.tsx @@ -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 { + 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([]); + 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 ( +
+ + + + {name} +
+ ); +} + +function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) { + const [hovered, setHovered] = useState(false); + return ( +
+ {hovered ? : null} + +
+ ); +} + /** * 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([]); + const [rawDbResults, setRawDbResults] = useState([]); const [searching, setSearching] = useState(false); const containerRef = useRef(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" /> -
+
{showingSearch ? ( searching ? (

Suche …

- ) : dbResults.length > 0 ? ( + ) : loadableDbResults.length > 0 ? (
- {dbResults.map((r) => ( - + /> ))}
) : (

Keine Treffer.

) - ) : existingFavicons.length > 0 ? ( + ) : loadableExisting.length > 0 ? ( <>

Bereits im System verwendet

- {existingFavicons.map(([iconUrl, name]) => ( - + /> ))}