import { useMemo, useState, type DragEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; import { useServices } from "../../hooks/useServices.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) { throw new 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 = 11; function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { const queryClient = useQueryClient(); 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); const portNumber = Number(port) || service.port; const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`; const mutation = useMutation({ mutationFn: () => patchService(service.id, { displayName: displayName.trim(), category: category.trim() || null, alias: alias .split(",") .map((a) => a.trim()) .filter(Boolean), order: Number(order) || 0, hostname: hostname.trim(), port: portNumber, https, url: previewUrl, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["categories"] }); onDone(); }, }); return (
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}
); } function ServiceRow({ service, deviceMac, draggable, onDragStart, onDragOver, onDrop, isDragging, }: { service: Service; deviceMac: 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.visible ? ( ausgeblendet ) : null}
{service.hostname} {service.category ?? "–"} {deviceMac ?? "–"} {service.alias.length > 0 ? service.alias.join(", ") : "–"} {service.port} {service.https ? "https" : "http"} öffnen
); } type SortColumn = "displayName" | "hostname" | "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 macByDeviceId = useMemo(() => { const map: Record = {}; for (const d of devices ?? []) map[d.id] = d.mac; 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 list = useMemo(() => { if (!sortColumn) return baseList; const sorted = [...baseList].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 "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; }, [baseList, sortColumn, sortDirection]); 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 (
0 ? `Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).` : "Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." } /> {sortColumn ? (
) : null} {isLoading ? (

Lade Dienste …

) : isError ? (

Dienste konnten nicht geladen werden.

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

Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben.

)}
); }