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]) => (
-
+ />
))}
>