diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index ac7d182..705319f 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -16,6 +16,7 @@ import { recentVisitsRoutes } from "./routes/recentVisits.js"; import { settingsRoutes } from "./routes/settings.js"; import { readLaterRoutes } from "./routes/readLater.js"; import { faviconProxyRoutes } from "./routes/faviconProxy.js"; +import { iconsRoutes } from "./routes/icons.js"; import { loadPlugins } from "./plugins/loader.js"; import { startLiveStatusHeartbeat } from "./liveStatus.js"; import * as serviceRepo from "./db/repositories/services.js"; @@ -76,6 +77,7 @@ async function main() { await app.register(settingsRoutes); await app.register(readLaterRoutes); await app.register(faviconProxyRoutes); + await app.register(iconsRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/liveStatus.ts b/apps/backend/src/liveStatus.ts index ba8bd31..d820903 100644 --- a/apps/backend/src/liveStatus.ts +++ b/apps/backend/src/liveStatus.ts @@ -44,6 +44,7 @@ export function startLiveStatusHeartbeat(): void { const online = await pingHost(device.ip); deviceRepo.updateDeviceOnlineStatus(device.id, online); } + settingsRepo.setSetting("liveStatusLastRunAt", new Date().toISOString()); } finally { running = false; } diff --git a/apps/backend/src/routes/icons.ts b/apps/backend/src/routes/icons.ts new file mode 100644 index 0000000..32d3d82 --- /dev/null +++ b/apps/backend/src/routes/icons.ts @@ -0,0 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import { searchIconDb } from "../scanner/iconDb.js"; + +export async function iconsRoutes(app: FastifyInstance): Promise { + app.get("/api/icons/search", async (request) => { + const { q } = request.query as { q?: string }; + const results = await searchIconDb(q ?? ""); + return { results }; + }); +} diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 1d7ae9f..4f0dbf2 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -10,6 +10,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), liveStatusEnabled: all.liveStatusEnabled === "true", liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5), + liveStatusLastRunAt: all.liveStatusLastRunAt ?? null, }; }); @@ -67,6 +68,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), liveStatusEnabled: all.liveStatusEnabled === "true", liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5), + liveStatusLastRunAt: all.liveStatusLastRunAt ?? null, }; }); } diff --git a/apps/backend/src/scanner/iconDb.ts b/apps/backend/src/scanner/iconDb.ts new file mode 100644 index 0000000..ac84b35 --- /dev/null +++ b/apps/backend/src/scanner/iconDb.ts @@ -0,0 +1,81 @@ +import { request } from "node:https"; + +const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json"; +const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag + +let cachedNames: string[] | null = null; +let cachedAt = 0; +let inFlight: Promise | null = null; + +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const req = request(url, { timeout: 8000 }, (res) => { + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}`)); + res.resume(); + return; + } + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8"))); + } catch (e) { + reject(e); + } + }); + }); + req.on("timeout", () => req.destroy(new Error("Timeout"))); + req.on("error", reject); + req.end(); + }); +} + +/** + * Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem + * homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für + * Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert). + * + * Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen + * Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst) + * - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der + * Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die + * Suche einfach keine Treffer statt einen Fehler zu werfen. + */ +async function getIconNames(): Promise { + if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames; + if (inFlight) return inFlight; + + inFlight = (async () => { + try { + const tree = (await fetchJson(TREE_URL)) as { png?: string[] }; + const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, "")); + cachedNames = names; + cachedAt = Date.now(); + return names; + } catch { + // Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl + // (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter. + return cachedNames ?? []; + } finally { + inFlight = null; + } + })(); + + return inFlight; +} + +export interface IconSearchResult { + name: string; + url: string; +} + +export async function searchIconDb(query: string, limit = 24): Promise { + const q = query.trim().toLowerCase(); + if (q.length < 2) return []; + + const names = await getIconNames(); + const matches = names.filter((n) => n.includes(q)).slice(0, limit); + return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` })); +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index af7638a..576afdb 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -136,16 +136,19 @@ export async function scanDeviceServices( }); } - // Fallback-Reihenfolge für Dienste ohne bestätigtes Favicon (kein - // im HTML gefunden): zuerst das Favicon eines ANDEREN - // Dienstes auf demselben Gerät übernehmen (typischerweise dieselbe - // Software/dasselbe Gerät, das Icon passt meist trotzdem) - erst wenn auch - // das fehlt, wird als letzter Ausweg "/favicon.ico" am eigenen Port - // geraten (kann ins Leere laufen, ist aber besser als gar kein Versuch). + // Fallback für Dienste ohne bestätigtes Favicon (kein + // im HTML gefunden): das Favicon eines ANDEREN Dienstes auf demselben + // Gerät übernehmen (typischerweise dieselbe Software/dasselbe Gerät, das + // Icon passt meist trotzdem). Gibt es auch das nicht, bleibt favicon + // bewusst leer (Buchstaben-Fallback in der UI) statt blind auf + // "/favicon.ico" zu raten - eine geratene, unbestätigte Adresse sähe in + // der Favicon-Auswahl (Bearbeiten-Formular) wie ein echtes Icon aus, + // obwohl sie oft ins Leere läuft. const firstSiblingFavicon = found.find((s) => s.favicon)?.favicon; - for (const service of found) { - if (service.favicon) continue; - service.favicon = firstSiblingFavicon ?? `${service.url}/favicon.ico`; + if (firstSiblingFavicon) { + for (const service of found) { + if (!service.favicon) service.favicon = firstSiblingFavicon; + } } return { services: found, suggestedHostname }; diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index 84be5b8..ca4f619 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -6,6 +6,7 @@ export interface AppSettings { staleDeviceThresholdDays: number; liveStatusEnabled: boolean; liveStatusIntervalMinutes: number; + liveStatusLastRunAt: string | null; } async function fetchSettings(): Promise { diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 7c20326..df59158 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -267,9 +267,16 @@ export function HomePage() { const isOnline = !healthError && health?.status === "ok"; const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0; + const readLaterLimit = settings?.readLaterLimit ?? 5; + const hasReadLaterChips = readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0; + const showShelf = !isSearching || !resultsVisible; + const showResults = isSearching && resultsVisible; return ( -
+
- {/* Nicht-scrollender Kopfbereich: Titel, Favoriten, Suchfeld, Später-lesen, Zuletzt besucht */} -
+ {/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht. + Nur im "aktiv suchend"-Zustand shrink-0 + scrollender Bereich + darunter - sonst normaler Dokumentfluss, damit der Footer direkt + nach dem Inhalt folgt statt mit einer riesigen Lücke ganz unten zu + kleben (siehe showResults-Fallunterscheidung oben). */} +

LaunchPad @@ -301,37 +312,6 @@ export function HomePage() {

- {hasFavorites ? ( -
- {favoriteServices.length > 0 ? ( - { - const service = favoriteServices.find((s) => s.id === item.id); - if (service) openItem({ ...service, kind: "service" }); - }} - onReorder={(ids) => reorderServices.mutate(ids)} - /> - ) : null} - {favoriteBookmarks.length > 0 ? ( -
0 ? "pt-3" : ""}> - { - const bookmark = favoriteBookmarks.find((b) => b.id === item.id); - if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); - }} - onReorder={(ids) => reorderBookmarks.mutate(ids)} - /> -
- ) : null} -
- ) : null} - +
- {!isSearching || !resultsVisible ? ( -
- {(() => { - const readLaterLimit = settings?.readLaterLimit ?? 5; - const hasReadLaterChips = - readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0; - return ( -
-
- - Später lesen - - -
- {hasReadLaterChips ? ( -
- {readLaterItems!.slice(0, readLaterLimit).map((item) => ( - - ))} -
- ) : null} -
- ); - })()} - - {recentVisits && recentVisits.length > 0 ? ( -
- - Zuletzt besucht - + {showShelf ? ( +
+ {hasFavorites ? ( +
+ {favoriteServices.length > 0 ? ( { - const original = recentVisits.find((r) => r.id === item.id); - if (original) openItem(original); + const service = favoriteServices.find((s) => s.id === item.id); + if (service) openItem({ ...service, kind: "service" }); }} + onReorder={(ids) => reorderServices.mutate(ids)} /> + ) : null} + {favoriteBookmarks.length > 0 ? ( + { + const bookmark = favoriteBookmarks.find((b) => b.id === item.id); + if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); + }} + onReorder={(ids) => reorderBookmarks.mutate(ids)} + /> + ) : null} +
+ ) : null} + + {recentVisits && recentVisits.length > 0 ? ( + { + const original = recentVisits.find((r) => r.id === item.id); + if (original) openItem(original); + }} + /> + ) : null} + +
+
+ + Später lesen + + +
+ {hasReadLaterChips ? ( +
+ {readLaterItems!.slice(0, readLaterLimit).map((item) => ( + + ))}
- ) : null} + ) : ( +

+ Noch nichts gemerkt. +

+ )}
- ) : null} -
+
+ ) : null}
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */} - {isSearching && resultsVisible ? ( + {showResults ? (
{isLoading ? ( @@ -432,11 +437,9 @@ export function HomePage() { )}
- ) : ( -
- )} + ) : null} -
+
{health ? ( diff --git a/apps/frontend/src/routes/admin/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx index 3b4d80b..d35fb5a 100644 --- a/apps/frontend/src/routes/admin/BookmarksPage.tsx +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { faStar } from "@fortawesome/free-regular-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button, Favicon } from "@launchpad/ui"; +import { Button, Favicon, FaviconPicker } from "@launchpad/ui"; import type { Bookmark } from "@launchpad/shared"; import { suggestBookmarkCategory } from "@launchpad/shared"; import { useBookmarks } from "../../hooks/useBookmarks.js"; @@ -216,7 +216,6 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); const FAVICON_MAX_BYTES = 300 * 1024; const existingFavicons = useMemo(() => { @@ -283,16 +282,7 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void onChange={(e) => handleFaviconFile(e.target.files?.[0])} /> - {existingFavicons.length > 0 ? ( - - ) : null} + setFavicon(url)} /> {faviconPreview ? (
{faviconError ?

{faviconError}

: null} - {pickerOpen ? ( -
- {existingFavicons.map(([iconUrl, name]) => ( - - ))} -
- ) : null}
diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 430e7b6..3778404 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -3,7 +3,7 @@ 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 { faStar } from "@fortawesome/free-regular-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button, Favicon } from "@launchpad/ui"; +import { Button, Favicon, FaviconPicker } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; import { useServices } from "../../hooks/useServices.js"; import { useBookmarks } from "../../hooks/useBookmarks.js"; @@ -125,7 +125,6 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) // string = neu hochgeladenes Favicon als data:-URL. const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); // Bereits verwendete Favicons (Dienste + Lesezeichen), zur Auswahl im // "Vorhandenes Favicon wählen"-Picker - z. B. wenn mehrere Dienste @@ -211,16 +210,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) onChange={(e) => handleFaviconFile(e.target.files?.[0])} /> - {existingFavicons.length > 0 ? ( - - ) : null} + setFavicon(url)} /> {faviconPreview ? (
{faviconError ?

{faviconError}

: null} - {pickerOpen ? ( -
- {existingFavicons.map(([iconUrl, name]) => ( - - ))} -
- ) : null}
diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index fa0eec5..6ad67d4 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -46,8 +46,8 @@ function LiveStatusToggle({ enabled }: { enabled: boolean }) { }`} > @@ -385,6 +385,16 @@ export function SettingsPage() { vorbehalten. Standardmäßig aus.

+ {settings?.liveStatusEnabled ? ( +

+ {settings.liveStatusLastRunAt + ? `Letzter Durchlauf: ${new Date(settings.liveStatusLastRunAt).toLocaleString("de-DE", { + dateStyle: "medium", + timeStyle: "short", + })}` + : "Noch kein Durchlauf seit dem Aktivieren."} +

+ ) : null} {settings?.liveStatusEnabled ? ( void; +} + +interface IconDbResult { + name: string; + url: string; +} + +/** + * Ein Button, der einen Picker öffnet, der ZWEI Favicon-Quellen kombiniert: + * bereits im System verwendete Favicons (z. B. von anderen Diensten) und + * eine Live-Suche in der externen Icon-Datenbank (homarr-labs/dashboard- + * 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. + */ +export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [dbResults, setDbResults] = useState([]); + const [searching, setSearching] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open]); + + useEffect(() => { + const q = query.trim(); + if (q.length < 2) { + setDbResults([]); + 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([])) + .finally(() => setSearching(false)); + }, 300); + return () => clearTimeout(handle); + }, [query]); + + const showingSearch = query.trim().length >= 2; + + return ( +
+ + + {open ? ( +
+ setQuery(e.target.value)} + placeholder="Icon-Datenbank durchsuchen … z. B. „plex“" + className="mb-2 w-full rounded-lg border border-black/10 bg-white px-2 py-1 text-xs + text-black outline-none placeholder:text-black/30 focus:border-black/30 + dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30" + /> + +
+ {showingSearch ? ( + searching ? ( +

Suche …

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

Keine Treffer.

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

+ Bereits im System verwendet +

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

+ Noch keine anderen Favicons vorhanden. Tippe oben, um die Icon-Datenbank zu durchsuchen. +

+ )} +
+
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx index 0b50c82..1fd786b 100644 --- a/packages/ui/src/FavoritesBar.tsx +++ b/packages/ui/src/FavoritesBar.tsx @@ -16,13 +16,15 @@ export interface FavoritesBarProps { /** Kategoriename -> Hex-Farbe, zeigt sich als Ring um das Icon. */ categoryColors?: Record; onOpen: (item: FavoriteItem) => void; - /** Wenn gesetzt, sind die Chips per Drag & Drop sortierbar. */ + /** Wenn gesetzt, sind die Kacheln per Drag & Drop sortierbar. */ onReorder?: (orderedIds: string[]) => void; } /** - * Zeigt Favoriten als kompakte, nur-Icon-Chips – Name erscheint als Tooltip - * beim Hover, nicht als sichtbarer Text (bewusst platzsparend). Anklickbar, + * Zeigt Einträge als Icon-Kacheln mit sichtbarer Beschriftung darunter + * (Launchpad-/Homescreen-Stil) statt als reine Icon-Chips mit Tooltip - + * lesbarer und wirkt strukturierter, wenn mehrere Gruppen (Favoriten, + * Zuletzt besucht, Später lesen) auf derselben Seite stehen. Anklickbar, * per Drag & Drop sortierbar. Dienste und Lesezeichen werden über getrennte * FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein generisch * über FavoriteItem statt fest an Service gebunden. @@ -72,15 +74,11 @@ export function FavoritesBar({ return (
{label ? ( -
+
{label}
) : null} -
+
{list.map((item) => { const ringColor = item.category ? categoryColors?.[item.category] : undefined; return ( @@ -97,14 +95,22 @@ export function FavoritesBar({ ? `${item.displayName} (${item.hostname}:${item.port})` : `${item.displayName} (${item.hostname})` } - style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined} - className={`flex h-11 w-11 items-center justify-center rounded-xl border - border-black/10 bg-white/70 shadow-sm transition-colors hover:bg-black/5 - dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10 ${ + className={`group flex w-16 flex-col items-center gap-1.5 rounded-xl p-1.5 + transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${ onReorder ? "cursor-grab active:cursor-grabbing" : "" } ${draggedId === item.id ? "opacity-40" : ""}`} > - + + + + + {item.displayName} + ); })} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 972db44..b2cf950 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -15,3 +15,6 @@ export type { FavoritesBarProps } from "./FavoritesBar.js"; export { Favicon } from "./Favicon.js"; export type { FaviconProps } from "./Favicon.js"; + +export { FaviconPicker } from "./FaviconPicker.js"; +export type { FaviconPickerProps } from "./FaviconPicker.js";