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 { faStar } from "@fortawesome/free-regular-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; 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"; import { useCategories } from "../../hooks/useCategories.js"; import { useDevices } from "../../hooks/useDevices.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; interface ServicePatch { displayName?: string; category?: string | null; alias?: string[]; order?: number; favorite?: boolean; visible?: boolean; hostname?: string; port?: number; https?: boolean; url?: string; } async function patchService(id: string, patch: ServicePatch): Promise { const res = await fetch(`/api/services/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`); } return res.json(); } async function deleteServiceRequest(id: string) { const res = await fetch(`/api/services/${id}`, { method: "DELETE" }); if (!res.ok && res.status !== 404) { throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`); } } async function reorderServicesRequest(entries: { id: string; order: number }[]) { const res = await fetch("/api/services/reorder", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entries), }); if (!res.ok) { throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`); } return res.json(); } const NEW_CATEGORY_VALUE = "__new__"; function CategorySelect({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { const { data: categories } = useCategories(); const isKnown = !value || categories?.some((c) => c.name === value); const [isNew, setIsNew] = useState(!isKnown); return (
{isNew ? ( onChange(e.target.value)} placeholder="Name der neuen Kategorie" autoFocus className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" /> ) : null}
); } const EDIT_FORM_COLSPAN = 12; function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { const queryClient = useQueryClient(); const { data: allServices } = useServices(); const { data: allBookmarks } = useBookmarks(); const [displayName, setDisplayName] = useState(service.displayName); const [category, setCategory] = useState(service.category ?? ""); const [alias, setAlias] = useState(service.alias.join(", ")); const [order, setOrder] = useState(String(service.order)); const [hostname, setHostname] = useState(service.hostname); const [port, setPort] = useState(String(service.port)); const [https, setHttps] = useState(service.https); // undefined = unverändert (Feld nicht anfassen), null = Nutzer hat's // explizit entfernt (fällt zurück auf automatische Erkennung/Buchstabe), // string = neu hochgeladenes Favicon als data:-URL. const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); // Bereits verwendete Favicons (Dienste + Lesezeichen), zur Auswahl im // "Vorhandenes Favicon wählen"-Picker - z. B. wenn mehrere Dienste // desselben Geräts eigentlich dasselbe Icon zeigen sollten, der Scan aber // nur bei einem davon eins gefunden hat. const existingFavicons = useMemo(() => { const seen = new Map(); // favicon-URL -> Anzeigename für Tooltip for (const s of allServices ?? []) { if (s.favicon && s.id !== service.id && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName); } for (const b of allBookmarks ?? []) { if (b.favicon && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName); } return Array.from(seen.entries()); }, [allServices, allBookmarks, service.id]); const FAVICON_MAX_BYTES = 300 * 1024; function handleFaviconFile(file: File | undefined) { setFaviconError(null); if (!file) return; if (file.size > FAVICON_MAX_BYTES) { setFaviconError("Datei zu groß (max. 300 KB) – bitte ein kleineres Bild wählen."); return; } const reader = new FileReader(); reader.onload = () => setFavicon(reader.result as string); reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden."); reader.readAsDataURL(file); } const portNumber = Number(port) || service.port; const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`; const faviconPreview = favicon === undefined ? service.favicon : favicon; const mutation = useMutation({ mutationFn: () => { const patch: Record = { alias: alias .split(",") .map((a) => a.trim()) .filter(Boolean), order: Number(order) || 0, hostname: hostname.trim(), port: portNumber, https, url: previewUrl, }; // displayName/category nur mitschicken, wenn sich der Wert tatsächlich // geändert hat - sonst würde jedes Speichern (z. B. nur um den Port zu // korrigieren) die *EditedManually-Flag fälschlich setzen, obwohl der // Nutzer diese beiden Felder gar nicht angefasst hat (siehe // upsertServiceFromScan/updateService-Doku in services.ts). const trimmedName = displayName.trim(); if (trimmedName !== service.displayName) patch.displayName = trimmedName; const trimmedCategory = category.trim() || null; if (trimmedCategory !== service.category) patch.category = trimmedCategory; if (favicon !== undefined) patch.favicon = favicon; return patchService(service.id, patch); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["categories"] }); onDone(); }, }); return (
setFavicon(url)} /> {faviconPreview ? ( ) : null}
{faviconError ?

{faviconError}

: null}
setDisplayName(e.target.value)} className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setAlias(e.target.value)} className="w-48 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setOrder(e.target.value)} type="number" className="w-20 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setHostname(e.target.value)} className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setPort(e.target.value)} type="number" min={1} max={65535} className="w-24 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
Öffnet: {previewUrl}
{mutation.isError ? ( {(mutation.error as Error).message} ) : null}
); } function ServiceRow({ service, deviceMac, deviceIp, deviceHostname, draggable, onDragStart, onDragOver, onDrop, isDragging, }: { service: Service; deviceMac: string | null; deviceIp: string | null; deviceHostname: string | null; draggable: boolean; onDragStart: (e: DragEvent) => void; onDragOver: (e: DragEvent) => void; onDrop: (e: DragEvent) => void; isDragging: boolean; }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const favoriteMutation = useMutation({ mutationFn: () => patchService(service.id, { favorite: !service.favorite }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), }); const visibleMutation = useMutation({ mutationFn: () => patchService(service.id, { visible: !service.visible }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), }); const deleteMutation = useMutation({ mutationFn: () => deleteServiceRequest(service.id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), }); if (editing) { return setEditing(false)} />; } return ( ⠿⠿
{service.displayName} {service.displayNameEditedManually ? ( ) : null} {!service.visible ? ( ausgeblendet ) : null}
{/* Der gespeicherte Hostname ist oft nur die IP (wenn DNS nichts Besseres auflösen konnte) - dann lieber den bekannten Gerätenamen zeigen statt der IP doppelt (die steht schon in der Spalte daneben). Der technische Wert bleibt beim Bearbeiten unverändert editierbar/verwendbar, hier geht es nur um die Anzeige. */} {service.hostname === deviceIp && deviceHostname ? ( {deviceHostname} ) : ( service.hostname )} {deviceIp ?? "–"} {service.category ?? "–"} {service.categoryEditedManually ? ( ) : null} {deviceMac ?? "–"} {service.alias.length > 0 ? service.alias.join(", ") : "–"} {service.port} {service.https ? "https" : "http"} öffnen
); } type SortColumn = "displayName" | "hostname" | "ip" | "category" | "alias" | "port" | "https" | null; function SortableHeader({ label, column, activeColumn, direction, onClick, }: { label: string; column: SortColumn; activeColumn: SortColumn; direction: "asc" | "desc"; onClick: (column: SortColumn) => void; }) { const active = activeColumn === column; return ( ); } export function ServicesPage() { const { data: services, isLoading, isError } = useServices(); const { data: devices } = useDevices(); const queryClient = useQueryClient(); const [draggedId, setDraggedId] = useState(null); const [localOrder, setLocalOrder] = useState(null); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible"); const macByDeviceId = useMemo(() => { const map: Record = {}; for (const d of devices ?? []) map[d.id] = d.mac; return map; }, [devices]); const ipByDeviceId = useMemo(() => { const map: Record = {}; for (const d of devices ?? []) map[d.id] = d.ip; return map; }, [devices]); const hostnameByDeviceId = useMemo(() => { const map: Record = {}; for (const d of devices ?? []) map[d.id] = d.hostname; return map; }, [devices]); const reorderMutation = useMutation({ mutationFn: reorderServicesRequest, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["services"] }); setLocalOrder(null); }, onError: () => setLocalOrder(null), }); const baseList = localOrder ?? services ?? []; const hiddenCount = services?.filter((s) => !s.visible).length ?? 0; const visibleCount = services ? services.length - hiddenCount : 0; const tabFilteredBase = useMemo( () => baseList.filter((s) => (activeTab === "visible" ? s.visible : !s.visible)), [baseList, activeTab] ); const list = useMemo(() => { if (!sortColumn) return tabFilteredBase; const sorted = [...tabFilteredBase].sort((a, b) => { let cmp = 0; switch (sortColumn) { case "displayName": cmp = a.displayName.localeCompare(b.displayName); break; case "hostname": cmp = a.hostname.localeCompare(b.hostname); break; case "ip": cmp = (ipByDeviceId[a.deviceId] ?? "").localeCompare( ipByDeviceId[b.deviceId] ?? "", undefined, { numeric: true } ); break; case "category": cmp = (a.category ?? "").localeCompare(b.category ?? ""); break; case "alias": cmp = a.alias.join(",").localeCompare(b.alias.join(",")); break; case "port": cmp = a.port - b.port; break; case "https": cmp = Number(a.https) - Number(b.https); break; } return sortDirection === "asc" ? cmp : -cmp; }); return sorted; }, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]); function handleHeaderClick(column: SortColumn) { if (sortColumn === column) { setSortDirection((d) => (d === "asc" ? "desc" : "asc")); } else { setSortColumn(column); setSortDirection("asc"); } } const dragEnabled = sortColumn === null; function handleDragStart(id: string) { return (_e: DragEvent) => setDraggedId(id); } function handleDragOver(targetId: string) { return (e: DragEvent) => { e.preventDefault(); if (!dragEnabled || !draggedId || draggedId === targetId) return; const current = localOrder ?? services ?? []; const fromIndex = current.findIndex((s) => s.id === draggedId); const toIndex = current.findIndex((s) => s.id === targetId); if (fromIndex === -1 || toIndex === -1) return; const next = [...current]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); setLocalOrder(next); }; } function handleDrop() { return (e: DragEvent) => { e.preventDefault(); if (!dragEnabled) return; setDraggedId(null); const current = localOrder ?? services ?? []; reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index }))); }; } return (
{sortColumn ? (
) : null} {isLoading ? (

Lade Dienste …

) : isError ? (

Dienste konnten nicht geladen werden.

) : list.length > 0 ? (
{list.map((service) => ( ))}
MAC URL
) : (

{activeTab === "hidden" ? "Keine ausgeblendeten Dienste." : "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}

)}
); }