generated from Dicken/dickendock
721 lines
28 KiB
TypeScript
721 lines
28 KiB
TypeScript
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<Service> {
|
||
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 (
|
||
<div className="flex flex-col gap-1">
|
||
<select
|
||
value={isNew ? NEW_CATEGORY_VALUE : value}
|
||
onChange={(e) => {
|
||
if (e.target.value === NEW_CATEGORY_VALUE) {
|
||
setIsNew(true);
|
||
onChange("");
|
||
} else {
|
||
setIsNew(false);
|
||
onChange(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"
|
||
>
|
||
<option value="">– Keine –</option>
|
||
{categories?.map((c) => (
|
||
<option key={c.id} value={c.name}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie …</option>
|
||
</select>
|
||
{isNew ? (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => 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}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string | null | undefined>(undefined);
|
||
const [faviconError, setFaviconError] = useState<string | null>(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<string, string>(); // 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<string, unknown> = {
|
||
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 (
|
||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Favicon</label>
|
||
<div className="flex items-center gap-2">
|
||
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
|
||
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
|
||
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
|
||
Hochladen
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
|
||
/>
|
||
</label>
|
||
<FaviconPicker existingFavicons={existingFavicons} onSelect={(url) => setFavicon(url)} />
|
||
{faviconPreview ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setFavicon(null)}
|
||
className="text-xs text-black/40 underline dark:text-white/40"
|
||
>
|
||
Entfernen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||
<CategorySelect value={category} onChange={setCategory} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Alias (kommagetrennt)
|
||
</label>
|
||
<input
|
||
value={alias}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Reihenfolge</label>
|
||
<input
|
||
value={order}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
|
||
<div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Hostname / IP
|
||
</label>
|
||
<input
|
||
value={hostname}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Port</label>
|
||
<input
|
||
value={port}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 flex items-center gap-1.5 text-xs text-black/50 dark:text-white/50">
|
||
<input
|
||
type="checkbox"
|
||
checked={https}
|
||
onChange={(e) => setHttps(e.target.checked)}
|
||
className="rounded"
|
||
/>
|
||
HTTPS
|
||
</label>
|
||
</div>
|
||
<div className="text-xs text-black/40 dark:text-white/40">
|
||
Öffnet: <span className="font-mono">{previewUrl}</span>
|
||
</div>
|
||
|
||
<div className="flex w-full items-center gap-2 pt-1">
|
||
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
|
||
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
|
||
{mutation.isError ? (
|
||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
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<HTMLTableRowElement>) => void;
|
||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
onDrop: (e: DragEvent<HTMLTableRowElement>) => 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 <EditForm service={service} onDone={() => setEditing(false)} />;
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
draggable={draggable}
|
||
onDragStart={onDragStart}
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
|
||
service.visible ? "" : "opacity-50"
|
||
} ${isDragging ? "opacity-40" : ""}`}
|
||
>
|
||
<td className="px-2 py-3 text-center">
|
||
<span
|
||
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
|
||
aria-hidden
|
||
>
|
||
⠿⠿
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
onClick={() => favoriteMutation.mutate()}
|
||
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||
>
|
||
<FontAwesomeIcon icon={service.favorite ? faStarSolid : faStar} />
|
||
</button>
|
||
<button
|
||
onClick={() => visibleMutation.mutate()}
|
||
aria-label={service.visible ? "In der Suche ausblenden" : "In der Suche einblenden"}
|
||
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
|
||
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
|
||
>
|
||
{service.visible ? <FontAwesomeIcon icon={faEye} /> : <FontAwesomeIcon icon={faEyeSlash} />}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
||
{service.displayNameEditedManually ? (
|
||
<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" />
|
||
</span>
|
||
) : null}
|
||
{!service.visible ? (
|
||
<span className="rounded-full bg-black/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/50 dark:bg-white/10 dark:text-white/50">
|
||
ausgeblendet
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
|
||
{/* 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 ? (
|
||
<span title={`Technisch: ${service.hostname}`}>{deviceHostname}</span>
|
||
) : (
|
||
service.hostname
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
|
||
{deviceIp ?? "–"}
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||
<span className="inline-flex items-center gap-1">
|
||
{service.category ?? "–"}
|
||
{service.categoryEditedManually ? (
|
||
<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" />
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
||
{deviceMac ?? "–"}
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||
service.https
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
|
||
}`}
|
||
>
|
||
{service.https ? "https" : "http"}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<a
|
||
href={service.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||
>
|
||
öffnen
|
||
</a>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
|
||
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<th className="px-4 py-2 font-medium">
|
||
<button
|
||
onClick={() => onClick(column)}
|
||
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||
active ? "text-black dark:text-white" : ""
|
||
}`}
|
||
>
|
||
{label}
|
||
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
|
||
</button>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
export function ServicesPage() {
|
||
const { data: services, isLoading, isError } = useServices();
|
||
const { data: devices } = useDevices();
|
||
const queryClient = useQueryClient();
|
||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
||
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||
const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible");
|
||
|
||
const macByDeviceId = useMemo(() => {
|
||
const map: Record<string, string | null> = {};
|
||
for (const d of devices ?? []) map[d.id] = d.mac;
|
||
return map;
|
||
}, [devices]);
|
||
|
||
const ipByDeviceId = useMemo(() => {
|
||
const map: Record<string, string | null> = {};
|
||
for (const d of devices ?? []) map[d.id] = d.ip;
|
||
return map;
|
||
}, [devices]);
|
||
|
||
const hostnameByDeviceId = useMemo(() => {
|
||
const map: Record<string, string | null> = {};
|
||
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<HTMLTableRowElement>) => setDraggedId(id);
|
||
}
|
||
|
||
function handleDragOver(targetId: string) {
|
||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||
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<HTMLTableRowElement>) => {
|
||
e.preventDefault();
|
||
if (!dragEnabled) return;
|
||
setDraggedId(null);
|
||
const current = localOrder ?? services ?? [];
|
||
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
|
||
};
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<AdminPageHeader
|
||
title="Dienste"
|
||
description="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."
|
||
/>
|
||
|
||
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10">
|
||
<button
|
||
onClick={() => setActiveTab("visible")}
|
||
className={`px-3 py-2 text-sm font-medium ${
|
||
activeTab === "visible"
|
||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
|
||
}`}
|
||
>
|
||
Sichtbar ({visibleCount})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("hidden")}
|
||
className={`px-3 py-2 text-sm font-medium ${
|
||
activeTab === "hidden"
|
||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
|
||
}`}
|
||
>
|
||
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
|
||
</button>
|
||
</div>
|
||
|
||
{sortColumn ? (
|
||
<div className="mb-3">
|
||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
||
) : isError ? (
|
||
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
||
) : list.length > 0 ? (
|
||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[1140px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||
<th className="px-2 py-2" />
|
||
<th className="px-2 py-2" />
|
||
<SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Host" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2 font-medium">MAC</th>
|
||
<SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Port" column="port" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Protokoll" column="https" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2 font-medium">URL</th>
|
||
<th className="px-4 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{list.map((service) => (
|
||
<ServiceRow
|
||
key={service.id}
|
||
service={service}
|
||
deviceMac={macByDeviceId[service.deviceId] ?? null}
|
||
deviceIp={ipByDeviceId[service.deviceId] ?? null}
|
||
deviceHostname={hostnameByDeviceId[service.deviceId] ?? null}
|
||
draggable={dragEnabled}
|
||
isDragging={draggedId === service.id}
|
||
onDragStart={handleDragStart(service.id)}
|
||
onDragOver={handleDragOver(service.id)}
|
||
onDrop={handleDrop()}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-black/40 dark:text-white/40">
|
||
{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."}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|