diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index a3cf51e..79633aa 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -15,6 +15,7 @@ import { bookmarkRoutes } from "./routes/bookmarks.js"; 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 { loadPlugins } from "./plugins/loader.js"; import * as serviceRepo from "./db/repositories/services.js"; import * as bookmarkRepo from "./db/repositories/bookmarks.js"; @@ -73,6 +74,7 @@ async function main() { await app.register(recentVisitsRoutes); await app.register(settingsRoutes); await app.register(readLaterRoutes); + await app.register(faviconProxyRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/faviconProxy.ts b/apps/backend/src/routes/faviconProxy.ts new file mode 100644 index 0000000..8e14806 --- /dev/null +++ b/apps/backend/src/routes/faviconProxy.ts @@ -0,0 +1,90 @@ +import type { FastifyInstance } from "fastify"; +import http from "node:http"; +import https from "node:https"; + +const MAX_BYTES = 2 * 1024 * 1024; // 2 MB reicht für jedes realistische Favicon +const TIMEOUT_MS = 4000; + +function fetchImage( + targetUrl: string +): Promise<{ statusCode: number; contentType: string; body: Buffer }> { + return new Promise((resolve, reject) => { + let parsed: URL; + try { + parsed = new URL(targetUrl); + } catch { + reject(new Error("Ungültige URL")); + return; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + reject(new Error("Nur http/https erlaubt")); + return; + } + + const client = parsed.protocol === "https:" ? https : http; + const req = client.get( + parsed, + { + timeout: TIMEOUT_MS, + // Homelab-Geräte haben oft selbstsignierte Zertifikate. + rejectUnauthorized: false, + }, + (res) => { + const statusCode = res.statusCode ?? 0; + if (statusCode >= 300 && statusCode < 400 && res.headers.location) { + res.resume(); + fetchImage(new URL(res.headers.location, targetUrl).toString()) + .then(resolve) + .catch(reject); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + res.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BYTES) { + req.destroy(); + reject(new Error("Bild zu groß")); + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + resolve({ + statusCode, + contentType: res.headers["content-type"] ?? "image/x-icon", + body: Buffer.concat(chunks), + }); + }); + res.on("error", reject); + } + ); + + req.on("timeout", () => req.destroy(new Error("Zeitüberschreitung"))); + req.on("error", reject); + }); +} + +export async function faviconProxyRoutes(app: FastifyInstance): Promise { + app.get("/api/favicon-proxy", async (request, reply) => { + const { url } = request.query as { url?: string }; + if (!url) { + return reply.code(400).send({ error: "url erforderlich" }); + } + + try { + const image = await fetchImage(url); + if (image.statusCode >= 400 || image.body.length === 0) { + return reply.code(404).send(); + } + reply.header("Cache-Control", "public, max-age=86400"); + reply.type(image.contentType); + return reply.send(image.body); + } catch { + // Icon nicht ladbar (Gerät offline, kaputte URL, ...) - 404 statt 500, + // damit das Frontend sauber auf den Buchstaben-Fallback zurückfällt. + return reply.code(404).send(); + } + }); +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index e56962c..034dfda 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -8,6 +8,19 @@ LaunchPad +
diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index f04c3ac..02f8d20 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -11,13 +11,6 @@ import { useCategories } from "../hooks/useCategories.js"; import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js"; import { useReadLater } from "../hooks/useReadLater.js"; -function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) { - window.open(item.url, "_blank", "noopener,noreferrer"); - if ("kind" in item && item.kind && item.id) { - recordVisit(item.kind, item.id); - } -} - async function toggleFavoriteRequest(item: SearchResult): Promise { const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`; const res = await fetch(path, { @@ -95,6 +88,7 @@ export function HomePage() { const [theme, toggleTheme] = useTheme(); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); + const [resultsVisible, setResultsVisible] = useState(false); const { health, error: healthError } = useBackendHealth(); const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices(); const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks(); @@ -102,10 +96,20 @@ export function HomePage() { const { data: recentVisits } = useRecentVisits(); const { data: readLaterItems } = useReadLater(); const inputRef = useRef(null); + const searchContainerRef = useRef(null); const queryClient = useQueryClient(); const isSearching = query.trim().length > 0; const isLoading = servicesLoading || bookmarksLoading; + function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) { + window.open(item.url, "_blank", "noopener,noreferrer"); + if ("kind" in item && item.kind && item.id) { + recordVisit(item.kind, item.id).then(() => { + queryClient.invalidateQueries({ queryKey: ["recent-visits"] }); + }); + } + } + const categoryColors = useMemo(() => { const map: Record = {}; for (const c of categories ?? []) { @@ -164,6 +168,25 @@ export function HomePage() { setSelectedIndex(0); }, [results.length, query]); + // Beim Tippen wieder einblenden (z. B. nachdem per Klick-außerhalb + // zugeklappt wurde und man weitertippt). + useEffect(() => { + if (isSearching) setResultsVisible(true); + }, [isSearching]); + + // Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein + // (Desktop-Verhalten, Text bleibt erhalten). + useEffect(() => { + function onPointerDown(e: MouseEvent) { + if (!searchContainerRef.current) return; + if (!searchContainerRef.current.contains(e.target as Node)) { + setResultsVisible(false); + } + } + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, []); + useEffect(() => { function onKeyDown(e: KeyboardEvent) { const isSlash = e.key === "/" && document.activeElement !== inputRef.current; @@ -223,19 +246,19 @@ export function HomePage() { {/* Nicht-scrollender Kopfbereich: Titel, Favoriten, Suchfeld, Später-lesen, Zuletzt besucht */} -
-
-

+
+
+

LaunchPad

-

+

Tippe, um deine Homelab-Dienste sofort zu öffnen.

-
+
{hasFavorites ? ( -
+
{favoriteServices.length > 0 ? ( setQuery(e.target.value)} + onClear={() => { + setQuery(""); + inputRef.current?.focus(); + }} + onFocus={() => setResultsVisible(true)} placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“" hint="⌘K" autoFocus /> - {!isSearching ? ( + {!isSearching || !resultsVisible ? (
@@ -295,26 +323,15 @@ export function HomePage() { ) : null} {recentVisits && recentVisits.length > 0 ? ( -
-
- Zuletzt besucht -
-
- {recentVisits.map((item) => ( - - ))} -
-
+ { + const original = recentVisits.find((r) => r.id === item.id); + if (original) openItem(original); + }} + /> ) : null}
) : null} @@ -322,7 +339,7 @@ export function HomePage() {
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */} - {isSearching ? ( + {isSearching && resultsVisible ? (
{isLoading ? ( diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index 1608d33..0d622c6 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -4,6 +4,7 @@ import { StatusBadge } from "@launchpad/ui"; import { useBackendHealth } from "../../hooks/useBackendHealth.js"; const NAV_ITEMS = [ + { to: "/", label: "Startseite", icon: "🏠" }, { to: "/admin/dashboard", label: "Dashboard", icon: "📊" }, { to: "/admin/devices", label: "Geräte", icon: "🖥️" }, { to: "/admin/services", label: "Dienste", icon: "🔗" }, @@ -84,7 +85,7 @@ export function AdminLayout() {
- - - Zurück zur Suche -
diff --git a/apps/frontend/src/routes/admin/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx index eb4633f..1811637 100644 --- a/apps/frontend/src/routes/admin/BookmarksPage.tsx +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -40,7 +40,8 @@ async function patchBookmark(id: string, patch: BookmarkPatch): Promise ({})); + throw new Error(body.error ?? `Lesezeichen konnte nicht aktualisiert werden (HTTP ${res.status})`); } return res.json(); } @@ -258,13 +259,16 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void dark:border-white/10 dark:bg-white/10 dark:text-white" />
-
+
+ {mutation.isError ? ( + {(mutation.error as Error).message} + ) : null}
diff --git a/apps/frontend/src/routes/admin/CategoriesPage.tsx b/apps/frontend/src/routes/admin/CategoriesPage.tsx index 3330ae5..b1c3a24 100644 --- a/apps/frontend/src/routes/admin/CategoriesPage.tsx +++ b/apps/frontend/src/routes/admin/CategoriesPage.tsx @@ -5,11 +5,11 @@ import type { Category } from "@launchpad/shared"; import { useCategories } from "../../hooks/useCategories.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; -async function createCategory(name: string): Promise { +async function createCategory(name: string, color?: string): Promise { const res = await fetch("/api/categories", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, color }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -18,14 +18,15 @@ async function createCategory(name: string): Promise { return res.json(); } -async function renameCategory(id: string, name: string): Promise { +async function patchCategory(id: string, patch: { name?: string; color?: string }): Promise { const res = await fetch(`/api/categories/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify(patch), }); if (!res.ok) { - throw new Error(`Kategorie konnte nicht umbenannt werden (HTTP ${res.status})`); + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `Kategorie konnte nicht aktualisiert werden (HTTP ${res.status})`); } return res.json(); } @@ -67,13 +68,18 @@ function CategoryRow({ const [name, setName] = useState(category.name); const renameMutation = useMutation({ - mutationFn: () => renameCategory(category.id, name.trim()), + mutationFn: () => patchCategory(category.id, { name: name.trim() }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["categories"] }); setEditing(false); }, }); + const colorMutation = useMutation({ + mutationFn: (color: string) => patchCategory(category.id, { color }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }), + }); + const deleteMutation = useMutation({ mutationFn: () => deleteCategoryRequest(category.id), onSuccess: () => { @@ -96,6 +102,20 @@ function CategoryRow({ ⠿⠿ + + {editing ? ( <> (null); const [localOrder, setLocalOrder] = useState(null); const createMutation = useMutation({ - mutationFn: () => createCategory(newName.trim()), + mutationFn: () => createCategory(newName.trim(), newColor), onSuccess: () => { setNewName(""); + setNewColor("#94a3b8"); queryClient.invalidateQueries({ queryKey: ["categories"] }); }, }); @@ -193,10 +215,22 @@ export function CategoriesPage() {
+
+ + setNewColor(e.target.value)} + className="h-9 w-12 cursor-pointer rounded-lg border border-black/10 bg-white + dark:border-white/10 dark:bg-white/5" + /> +
-
+
+ {mutation.isError ? ( + {(mutation.error as Error).message} + ) : null}
diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 0da6e87..63d0def 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -35,8 +35,15 @@ export default defineConfig({ ], }, workbox: { - // Zeigt beim Offline-Öffnen weiterhin die (gecachte) App-Shell an. + // Zeigt beim Offline-Öffnen weiterhin die (gecachte) App-Shell an - + // ABER NICHT für /api/* oder /ca.crt: ein - + // oder -Klick wird vom Browser als + // "Navigation" behandelt, und ohne dieses Denylist hätte der Service + // Worker dafür fälschlich die App-Shell (index.html) ausgeliefert + // statt die Anfrage ans Backend/an nginx durchzulassen (Ursache für + // "Export lädt weiße .htm-Seite" / "ca.crt enthält HTML-Code"). navigateFallback: "/index.html", + navigateFallbackDenylist: [/^\/api\//, /^\/ca\.crt$/], runtimeCaching: [ { // Zuletzt geladene Geräte/Dienste/Kategorien bleiben offline verfügbar diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 1e3cfd7..90806ec 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -46,7 +46,18 @@ export type ServiceCreateInput = z.infer; export const ServiceUpdateSchema = ServiceCreateSchema.omit({ deviceId: true, -}).partial(); +}) + .partial() + .extend({ + // Frontend schickt beim Leeren eines Felds bewusst null (nicht nur + // "Feld weglassen") - .partial() allein akzeptiert dafür nur + // string|undefined, ein PATCH mit category:null schlug daher bisher mit + // 400 fehl, ohne dass das Formular das sichtbar gemacht hätte. + category: z.string().nullable().optional(), + description: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + favicon: z.string().nullable().optional(), + }); export type ServiceUpdateInput = z.infer; /** Für Drag & Drop: neue Reihenfolge mehrerer Dienste auf einmal setzen. */ @@ -98,7 +109,14 @@ export const BookmarkCreateSchema = z.object({ }); export type BookmarkCreateInput = z.infer; -export const BookmarkUpdateSchema = BookmarkCreateSchema.partial(); +export const BookmarkUpdateSchema = BookmarkCreateSchema.partial().extend({ + // Siehe Kommentar bei ServiceUpdateSchema: null muss erlaubt sein, damit + // sich Kategorie/Beschreibung/Icons im Bearbeiten-Formular leeren lassen. + category: z.string().nullable().optional(), + description: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + favicon: z.string().nullable().optional(), +}); export type BookmarkUpdateInput = z.infer; /** Für Drag & Drop: neue Reihenfolge mehrerer Lesezeichen auf einmal setzen. */ diff --git a/packages/ui/src/Favicon.tsx b/packages/ui/src/Favicon.tsx index 01b9ca7..20f7d58 100644 --- a/packages/ui/src/Favicon.tsx +++ b/packages/ui/src/Favicon.tsx @@ -18,6 +18,18 @@ const SIZE_CLASSES: Record, string> = { * einem fehlgeschlagenen Ladeversuch (kaputte URL, 404, CORS) wird * stattdessen der erste Buchstabe des Namens gezeigt. */ +/** + * Externe/Geräte-Favicons laufen über den Backend-Proxy (/api/favicon-proxy): + * Ohne das würden auf der HTTPS-Seite viele http://-Favicons (typisch für + * Homelab-Geräte ohne eigenes TLS) vom Browser als "Mixed Content" blockiert + * und nie angezeigt - das Backend selbst fetcht sie ohne diese Einschränkung. + * Data-URLs (z. B. selbst hochgeladene Icons) werden direkt durchgereicht. + */ +function resolveFaviconSrc(src: string): string { + if (src.startsWith("data:")) return src; + return `/api/favicon-proxy?url=${encodeURIComponent(src)}`; +} + export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) { const dimension = SIZE_CLASSES[size]; const [failed, setFailed] = useState(false); @@ -46,7 +58,7 @@ export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) { bg-white p-0.5 ring-1 ring-black/5`} > setFailed(true)} diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx index 2d57135..0b50c82 100644 --- a/packages/ui/src/FavoritesBar.tsx +++ b/packages/ui/src/FavoritesBar.tsx @@ -72,7 +72,7 @@ export function FavoritesBar({ return (
{label ? ( -
+
{label}
) : null} @@ -98,8 +98,8 @@ export function FavoritesBar({ : `${item.displayName} (${item.hostname})` } style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined} - className={`flex h-10 w-10 items-center justify-center rounded-full border - border-black/10 bg-white/70 transition-colors hover:bg-black/5 + 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 ${ onReorder ? "cursor-grab active:cursor-grabbing" : "" } ${draggedId === item.id ? "opacity-40" : ""}`} diff --git a/packages/ui/src/SearchInput.tsx b/packages/ui/src/SearchInput.tsx index 8afa3f3..10c0001 100644 --- a/packages/ui/src/SearchInput.tsx +++ b/packages/ui/src/SearchInput.tsx @@ -3,6 +3,8 @@ import { forwardRef, type InputHTMLAttributes } from "react"; export interface SearchInputProps extends Omit, "type"> { /** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */ hint?: string; + /** Zeigt ein X zum Leeren des Felds, sobald Text eingegeben wurde. */ + onClear?: () => void; } /** @@ -10,7 +12,9 @@ export interface SearchInputProps extends Omit( - ({ hint, className = "", ...props }, ref) => { + ({ hint, onClear, className = "", ...props }, ref) => { + const hasValue = typeof props.value === "string" && props.value.length > 0; + return (
( dark:text-white dark:placeholder:text-white/30" {...props} /> + {hasValue && onClear ? ( + + ) : null} {hint ? (