generated from Dicken/dickendock
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:
@@ -328,9 +328,13 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showShelf ? (
|
{showShelf ? (
|
||||||
<div className="flex w-full max-w-xl flex-col gap-6">
|
<div
|
||||||
|
className="w-full max-w-xl divide-y divide-black/[0.06] rounded-2xl border
|
||||||
|
border-black/[0.06] bg-black/[0.015] dark:divide-white/[0.06] dark:border-white/[0.06]
|
||||||
|
dark:bg-white/[0.02]"
|
||||||
|
>
|
||||||
{hasFavorites ? (
|
{hasFavorites ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{favoriteServices.length > 0 ? (
|
{favoriteServices.length > 0 ? (
|
||||||
<FavoritesBar
|
<FavoritesBar
|
||||||
items={favoriteServices}
|
items={favoriteServices}
|
||||||
@@ -359,6 +363,7 @@ export function HomePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{recentVisits && recentVisits.length > 0 ? (
|
{recentVisits && recentVisits.length > 0 ? (
|
||||||
|
<div className="p-4">
|
||||||
<FavoritesBar
|
<FavoritesBar
|
||||||
items={recentVisits}
|
items={recentVisits}
|
||||||
label="Zuletzt besucht"
|
label="Zuletzt besucht"
|
||||||
@@ -368,9 +373,10 @@ export function HomePage() {
|
|||||||
if (original) openItem(original);
|
if (original) openItem(original);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div className="p-4">
|
||||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
|
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
|
||||||
Später lesen
|
Später lesen
|
||||||
|
|||||||
@@ -382,9 +382,20 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
{device.online ? "Online" : "Offline"}
|
{device.online ? "Online" : "Offline"}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[10px] text-black/30 dark:text-white/30">
|
<div className="text-[10px] text-black/30 dark:text-white/30">
|
||||||
{device.lastScan
|
{(() => {
|
||||||
? `zuletzt gesehen: ${new Date(device.lastScan).toLocaleString("de-DE")}`
|
// "Zuletzt gesehen" soll den neueren der beiden Zeitpunkte
|
||||||
: "nie gescannt"}
|
// 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")}`;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState, type DragEvent } from "react";
|
import { useMemo, useState, type DragEvent } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
|
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
|
||||||
@@ -398,7 +398,7 @@ function ServiceRow({
|
|||||||
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
||||||
{service.displayNameEditedManually ? (
|
{service.displayNameEditedManually ? (
|
||||||
<span title="Name wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
<span title="Name wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
||||||
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
|
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!service.visible ? (
|
{!service.visible ? (
|
||||||
@@ -429,7 +429,7 @@ function ServiceRow({
|
|||||||
{service.category ?? "–"}
|
{service.category ?? "–"}
|
||||||
{service.categoryEditedManually ? (
|
{service.categoryEditedManually ? (
|
||||||
<span title="Kategorie wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
<span title="Kategorie wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
||||||
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
|
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,6 +12,85 @@ interface IconDbResult {
|
|||||||
url: string;
|
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:
|
* Ein Button, der einen Picker öffnet, der ZWEI Favicon-Quellen kombiniert:
|
||||||
* bereits im System verwendete Favicons (z. B. von anderen Diensten) und
|
* 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
|
* icons, kuratierte Icons für Selfhosted-Software). Bewusst EIN Button statt
|
||||||
* zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem
|
* zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem
|
||||||
* weiteren Button überladen werden.
|
* 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) {
|
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [dbResults, setDbResults] = useState<IconDbResult[]>([]);
|
const [rawDbResults, setRawDbResults] = useState<IconDbResult[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -39,20 +124,24 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
setDbResults([]);
|
setRawDbResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
fetch(`/api/icons/search?q=${encodeURIComponent(q)}`)
|
fetch(`/api/icons/search?q=${encodeURIComponent(q)}`)
|
||||||
.then((res) => (res.ok ? res.json() : { results: [] }))
|
.then((res) => (res.ok ? res.json() : { results: [] }))
|
||||||
.then((body) => setDbResults(body.results ?? []))
|
.then((body) => setRawDbResults(body.results ?? []))
|
||||||
.catch(() => setDbResults([]))
|
.catch(() => setRawDbResults([]))
|
||||||
.finally(() => setSearching(false));
|
.finally(() => setSearching(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
|
const existingAsResults = existingFavicons.map(([url, name]) => ({ url, name }));
|
||||||
|
const loadableExisting = useLoadableOnly(existingAsResults);
|
||||||
|
const loadableDbResults = useLoadableOnly(rawDbResults);
|
||||||
|
|
||||||
const showingSearch = query.trim().length >= 2;
|
const showingSearch = query.trim().length >= 2;
|
||||||
|
|
||||||
return (
|
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"
|
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 ? (
|
{showingSearch ? (
|
||||||
searching ? (
|
searching ? (
|
||||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche …</p>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{dbResults.map((r) => (
|
{loadableDbResults.map((r) => (
|
||||||
<button
|
<IconTile
|
||||||
key={r.name}
|
key={r.url}
|
||||||
type="button"
|
url={r.url}
|
||||||
title={r.name}
|
name={r.name}
|
||||||
onClick={() => {
|
onSelect={(url) => {
|
||||||
onSelect(r.url);
|
onSelect(url);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery("");
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Keine Treffer.</p>
|
<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">
|
<p className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||||
Bereits im System verwendet
|
Bereits im System verwendet
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{existingFavicons.map(([iconUrl, name]) => (
|
{loadableExisting.map((r) => (
|
||||||
<button
|
<IconTile
|
||||||
key={iconUrl}
|
key={r.url}
|
||||||
type="button"
|
url={r.url}
|
||||||
title={name}
|
name={r.name}
|
||||||
onClick={() => {
|
onSelect={(url) => {
|
||||||
onSelect(iconUrl);
|
onSelect(url);
|
||||||
setOpen(false);
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user